diff --git a/.env.example b/.env.example index 46237514b5..6d71f204ea 100644 --- a/.env.example +++ b/.env.example @@ -107,6 +107,19 @@ NEXT_PUBLIC_HELPSCOUT_KEY= NEXT_PUBLIC_FRESHCHAT_TOKEN= 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 SEND_FEEDBACK_EMAIL= @@ -237,6 +250,12 @@ AUTH_BEARER_TOKEN_VERCEL= E2E_TEST_APPLE_CALENDAR_EMAIL="" 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 *********************************************************************************** # 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 @@ -264,3 +283,9 @@ E2E_TEST_OIDC_USER_EMAIL= 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 diff --git a/.github/workflows/pr-assign-team-label.yml b/.github/workflows/pr-assign-team-label.yml index f6c02d1fd5..ecb601f75c 100644 --- a/.github/workflows/pr-assign-team-label.yml +++ b/.github/workflows/pr-assign-team-label.yml @@ -13,4 +13,4 @@ jobs: with: repo-token: ${{ secrets.GH_ACCESS_TOKEN }} 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" diff --git a/apps/api/pages/api/event-types/_post.ts b/apps/api/pages/api/event-types/_post.ts index 1531485e7b..6eeb59f5c9 100644 --- a/apps/api/pages/api/event-types/_post.ts +++ b/apps/api/pages/api/event-types/_post.ts @@ -316,8 +316,13 @@ async function checkPermissions(req: NextApiRequest) { statusCode: 401, 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 */ - if (isAdmin && (!body.userId || !body.teamId)) + if (isAdmin && !body.userId && !body.teamId) throw new HttpError({ statusCode: 400, message: "`userId` or `teamId` required" }); } diff --git a/apps/storybook/package.json b/apps/storybook/package.json index 5cc269a431..3aae79cb0a 100644 --- a/apps/storybook/package.json +++ b/apps/storybook/package.json @@ -10,7 +10,7 @@ "@calcom/config": "*", "@calcom/dayjs": "*", "@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-dialog": "^1.0.4", "@radix-ui/react-dropdown-menu": "^2.0.5", @@ -49,7 +49,7 @@ "storybook-addon-designs": "^6.3.1", "storybook-addon-next": "^1.6.9", "storybook-react-i18next": "^1.1.2", - "tailwindcss": "^3.3.1", + "tailwindcss": "^3.3.3", "typescript": "^4.9.4", "vite": "^4.1.2" } diff --git a/apps/web/abTest/middlewareFactory.ts b/apps/web/abTest/middlewareFactory.ts index bb2115e47a..5e300cf0d6 100644 --- a/apps/web/abTest/middlewareFactory.ts +++ b/apps/web/abTest/middlewareFactory.ts @@ -1,16 +1,21 @@ import { getBucket } from "abTest/utils"; import type { NextMiddleware, NextRequest } from "next/server"; -import { NextResponse } from "next/server"; +import { NextResponse, URLPattern } from "next/server"; import z from "zod"; -const ROUTES: [RegExp, boolean][] = [ - [/^\/event-types$/, Boolean(process.env.APP_ROUTER_EVENT_TYPES_ENABLED)], -]; +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")]).default("legacy"); +const bucketSchema = z.union([z.literal("legacy"), z.literal("future")]); export const abTestMiddlewareFactory = (next: (req: NextRequest) => Promise>): NextMiddleware => @@ -21,7 +26,7 @@ export const abTestMiddlewareFactory = const override = req.cookies.has(FUTURE_ROUTES_OVERRIDE_COOKIE_NAME); - const route = ROUTES.find(([regExp]) => regExp.test(pathname)) ?? null; + const route = ROUTES.find(([regExp]) => regExp.test(req.url)) ?? null; const enabled = route !== null ? route[1] || override : false; @@ -35,16 +40,29 @@ export const abTestMiddlewareFactory = if (!safeParsedBucket.success) { // cookie does not exist or it has incorrect value + const bucket = getBucket(); - const res = NextResponse.next(response); - res.cookies.set(FUTURE_ROUTES_ENABLED_COOKIE_NAME, getBucket(), { expires: 1000 * 60 * 30 }); // 30 min in ms - return res; + 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); } - const bucketUrlPrefix = safeParsedBucket.data === "future" ? "future" : ""; + if (safeParsedBucket.data === "legacy") { + return response; + } const url = req.nextUrl.clone(); - url.pathname = `${bucketUrlPrefix}${pathname}/`; + url.pathname = `future${pathname}/`; return NextResponse.rewrite(url, response); }; diff --git a/apps/web/app/_trpc/HydrateClient.tsx b/apps/web/app/_trpc/HydrateClient.tsx new file mode 100644 index 0000000000..16cbeb6b9a --- /dev/null +++ b/apps/web/app/_trpc/HydrateClient.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { createHydrateClient } from "app/_trpc/createHydrateClient"; +import superjson from "superjson"; + +export const HydrateClient = createHydrateClient({ + transformer: superjson, +}); diff --git a/apps/web/app/_trpc/client.ts b/apps/web/app/_trpc/client.ts new file mode 100644 index 0000000000..6f1cb3a2eb --- /dev/null +++ b/apps/web/app/_trpc/client.ts @@ -0,0 +1,5 @@ +import type { AppRouter } from "@calcom/trpc/server/routers/_app"; + +import { createTRPCReact } from "@trpc/react-query"; + +export const trpc = createTRPCReact({}); diff --git a/apps/web/app/_trpc/createHydrateClient.tsx b/apps/web/app/_trpc/createHydrateClient.tsx new file mode 100644 index 0000000000..a281737896 --- /dev/null +++ b/apps/web/app/_trpc/createHydrateClient.tsx @@ -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 {children}; + }; +} diff --git a/apps/web/app/_trpc/trpc-provider.tsx b/apps/web/app/_trpc/trpc-provider.tsx new file mode 100644 index 0000000000..f6d2ed2817 --- /dev/null +++ b/apps/web/app/_trpc/trpc-provider.tsx @@ -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 ( + + + {dehydratedState ? {children} : children} + + + ); +}; diff --git a/apps/web/app/_utils.tsx b/apps/web/app/_utils.tsx new file mode 100644 index 0000000000..da2b389593 --- /dev/null +++ b/apps/web/app/_utils.tsx @@ -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, + getDescription: (t: TFunction) => 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, + }); +}; diff --git a/apps/web/app/error.tsx b/apps/web/app/error.tsx new file mode 100644 index 0000000000..b804d677ac --- /dev/null +++ b/apps/web/app/error.tsx @@ -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; + +const log = logger.getSubLogger({ prefix: ["[error]"] }); + +const CustomError: NextPage = (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 ( + + ); +}; + +export default CustomError; diff --git a/apps/web/app/future/(shared-page-wrapper)/(layout)/event-types/page.tsx b/apps/web/app/future/(shared-page-wrapper)/(layout)/event-types/page.tsx new file mode 100644 index 0000000000..246bcc5c90 --- /dev/null +++ b/apps/web/app/future/(shared-page-wrapper)/(layout)/event-types/page.tsx @@ -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; diff --git a/apps/web/app/future/(shared-page-wrapper)/(layout)/layout.tsx b/apps/web/app/future/(shared-page-wrapper)/(layout)/layout.tsx new file mode 100644 index 0000000000..7d78c3b422 --- /dev/null +++ b/apps/web/app/future/(shared-page-wrapper)/(layout)/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/apps/web/app/global-error.tsx b/apps/web/app/global-error.tsx new file mode 100644 index 0000000000..ecf7247dbb --- /dev/null +++ b/apps/web/app/global-error.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { type NextPage } from "next"; + +import CustomError, { type DefaultErrorProps } from "./error"; + +export const GlobalError: NextPage = (props) => { + return ( + + + + + + ); +}; + +export default GlobalError; diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 23d224b6e1..f17543074f 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,84 +1,63 @@ -import type { Metadata } from "next"; -import { headers as nextHeaders, cookies as nextCookies } from "next/headers"; +import { dir } from "i18next"; +import { headers, cookies } from "next/headers"; import Script from "next/script"; import React from "react"; import { getLocale } from "@calcom/features/auth/lib/getLocale"; import { IS_PRODUCTION } from "@calcom/lib/constants"; +import { prepareRootMetadata } from "@lib/metadata"; + import "../styles/globals.css"; -export const metadata: Metadata = { - icons: { - icon: [ - { - sizes: "32x32", - url: "/api/logo?type=favicon-32", - }, - { - sizes: "16x16", - url: "/api/logo?type=favicon-16", - }, - ], - apple: { - sizes: "180x180", - url: "/api/logo?type=apple-touch-icon", +export const generateMetadata = () => + prepareRootMetadata({ + twitterCreator: "@calcom", + twitterSite: "@calcom", + robots: { + index: false, + follow: false, }, - 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 ( - url: string, - headers: ReturnType, - cookies: ReturnType -) => { +const getInitialProps = async (url: string) => { const { pathname, searchParams } = new URL(url); const isEmbed = pathname.endsWith("/embed") || (searchParams?.get("embedType") ?? null) !== null; 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 newLocale = await getLocale({ headers, cookies }); - let direction = "ltr"; - - 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); - } + const req = { headers: headers(), cookies: cookies() }; + const newLocale = await getLocale(req); + const direction = dir(newLocale); 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 }) { - const headers = nextHeaders(); - const cookies = nextCookies(); + const h = headers(); - const fullUrl = headers.get("x-url") ?? ""; - const nonce = headers.get("x-csp") ?? ""; + const fullUrl = h.get("x-url") ?? ""; + 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 ( + style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined} + data-nextjs-router="app"> {!IS_PRODUCTION && process.env.VERCEL_ENV === "preview" && ( // eslint-disable-next-line @next/next/no-sync-scripts diff --git a/apps/web/components/PageWrapperAppDir.tsx b/apps/web/components/PageWrapperAppDir.tsx index e6541df55a..414e4009bc 100644 --- a/apps/web/components/PageWrapperAppDir.tsx +++ b/apps/web/components/PageWrapperAppDir.tsx @@ -1,5 +1,6 @@ "use client"; +import { type DehydratedState } from "@tanstack/react-query"; import type { SSRConfig } from "next-i18next"; import { Inter } from "next/font/google"; import localFont from "next/font/local"; @@ -10,7 +11,6 @@ import type { ReactNode } from "react"; import "@calcom/embed-core/src/embed-iframe"; 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 AppProviders from "@lib/app-providers-app-dir"; @@ -29,13 +29,14 @@ const calFont = localFont({ }); export type PageWrapperProps = Readonly<{ - getLayout: (page: React.ReactElement) => ReactNode; + getLayout: ((page: React.ReactElement) => ReactNode) | null; children: React.ReactElement; requiresLicense: boolean; - isThemeSupported: boolean; - isBookingPage: boolean; nonce: string | undefined; themeBasis: string | null; + dehydratedState?: DehydratedState; + isThemeSupported?: boolean; + isBookingPage?: boolean; i18n?: SSRConfig; }>; @@ -85,4 +86,4 @@ function PageWrapper(props: PageWrapperProps) { ); } -export default trpc.withTRPC(PageWrapper); +export default PageWrapper; diff --git a/apps/web/components/eventtype/EventTeamTab.tsx b/apps/web/components/eventtype/EventTeamTab.tsx index 49917235a3..014fde0ed1 100644 --- a/apps/web/components/eventtype/EventTeamTab.tsx +++ b/apps/web/components/eventtype/EventTeamTab.tsx @@ -76,6 +76,8 @@ const ChildrenEventTypesList = ({
{ onChange && onChange( diff --git a/apps/web/lib/app-providers-app-dir.tsx b/apps/web/lib/app-providers-app-dir.tsx index 95dbfb077a..2b2d57d2ea 100644 --- a/apps/web/lib/app-providers-app-dir.tsx +++ b/apps/web/lib/app-providers-app-dir.tsx @@ -1,4 +1,5 @@ import { TooltipProvider } from "@radix-ui/react-tooltip"; +import { TrpcProvider } from "app/_trpc/trpc-provider"; import { dir } from "i18next"; import type { Session } from "next-auth"; import { SessionProvider, useSession } from "next-auth/react"; @@ -255,26 +256,28 @@ const AppProviders = (props: PageWrapperProps) => { const isBookingPage = useIsBookingPage(); const RemainingProviders = ( - - - - - {/* 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 */} - - - - {props.children} - - - - - - - + + + + + + {/* 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 */} + + + + {props.children} + + + + + + + + ); if (isBookingPage) { diff --git a/apps/web/lib/metadata.ts b/apps/web/lib/metadata.ts new file mode 100644 index 0000000000..a1adf8d165 --- /dev/null +++ b/apps/web/lib/metadata.ts @@ -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, +}); diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index b3fc0e2a83..d14b59cb45 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -1,13 +1,16 @@ import { get } from "@vercel/edge-config"; import { collectEvents } from "next-collect/server"; -import type { NextMiddleware } from "next/server"; +import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; +import { getLocale } from "@calcom/features/auth/lib/getLocale"; import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry"; import { csp } from "@lib/csp"; -const middleware: NextMiddleware = async (req) => { +import { abTestMiddlewareFactory } from "./abTest/middlewareFactory"; + +const middleware = async (req: NextRequest): Promise> => { const url = req.nextUrl; const requestHeaders = new Headers(req.headers); @@ -61,6 +64,12 @@ const middleware: NextMiddleware = async (req) => { 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({ request: { headers: requestHeaders, @@ -90,11 +99,13 @@ export const config = { * Paths required by routingForms.handle */ "/apps/routing_forms/:path*", + "/event-types", + "/future/event-types/", ], }; export default collectEvents({ - middleware, + middleware: abTestMiddlewareFactory(middleware), ...nextCollectBasicSettings, cookieName: "__clnds", extend: extendEventData, diff --git a/apps/web/package.json b/apps/web/package.json index e565cb0d4e..aab6e07a2e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@calcom/web", - "version": "3.5.0", + "version": "3.5.1", "private": true, "scripts": { "analyze": "ANALYZE=true next build", @@ -45,7 +45,7 @@ "@hookform/resolvers": "^2.9.7", "@next-auth/prisma-adapter": "^1.0.4", "@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-dialog": "^1.0.4", "@radix-ui/react-dropdown-menu": "^2.0.5", @@ -170,7 +170,7 @@ "node-html-parser": "^6.1.10", "node-mocks-http": "^1.11.0", "postcss": "^8.4.18", - "tailwindcss": "^3.3.1", + "tailwindcss": "^3.3.3", "tailwindcss-animate": "^1.0.6", "ts-node": "^10.9.1", "typescript": "^4.9.4" diff --git a/apps/web/pages/event-types/index.tsx b/apps/web/pages/event-types/index.tsx index 7bde895793..3b6fcb599f 100644 --- a/apps/web/pages/event-types/index.tsx +++ b/apps/web/pages/event-types/index.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useAutoAnimate } from "@formkit/auto-animate/react"; import type { User } from "@prisma/client"; import { Trans } from "next-i18next"; diff --git a/apps/web/pages/signup.tsx b/apps/web/pages/signup.tsx index 0b0cb0e5a8..041dcd943d 100644 --- a/apps/web/pages/signup.tsx +++ b/apps/web/pages/signup.tsx @@ -37,7 +37,7 @@ type SignupProps = inferSSRProps; const checkValidEmail = (email: string) => z.string().email().safeParse(email).success; const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string) => { - const [emailUser, emailDomain] = email.split("@"); + const [emailUser, emailDomain = ""] = email.split("@"); const username = emailDomain === autoAcceptEmailDomain ? slugify(emailUser) @@ -143,7 +143,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoA methods.clearErrors("apiError"); } - if (methods.getValues().username === undefined && isOrgInviteByLink && orgAutoAcceptEmail) { + if (!methods.getValues().username && isOrgInviteByLink && orgAutoAcceptEmail) { methods.setValue( "username", getOrgUsernameFromEmail(methods.getValues().email, orgAutoAcceptEmail) diff --git a/apps/web/playwright/event-types.e2e.ts b/apps/web/playwright/event-types.e2e.ts index 1bf95b69c3..0358f4ee9f 100644 --- a/apps/web/playwright/event-types.e2e.ts +++ b/apps/web/playwright/event-types.e2e.ts @@ -5,16 +5,47 @@ import { WEBAPP_URL } from "@calcom/lib/constants"; import { randomString } from "@calcom/lib/random"; import { test } from "./lib/fixtures"; +import { testBothFutureAndLegacyRoutes } from "./lib/future-legacy-routes"; import { bookTimeSlot, createNewEventType, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils"; 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("user", () => { + testBothFutureAndLegacyRoutes.describe("user", () => { test.beforeEach(async ({ page, users }) => { const user = await users.create(); await user.apiLogin(); await page.goto("/event-types"); + // We wait until loading is finished await page.waitForSelector('[data-testid="event-types"]'); }); diff --git a/apps/web/playwright/fixtures/clipboard.ts b/apps/web/playwright/fixtures/clipboard.ts new file mode 100644 index 0000000000..47cc92d95c --- /dev/null +++ b/apps/web/playwright/fixtures/clipboard.ts @@ -0,0 +1,34 @@ +import type { Page } from "@playwright/test"; + +declare global { + interface Window { + E2E_CLIPBOARD_VALUE?: string; + } +} + +export type Window = typeof window; +// creates the single server fixture +export const createClipboardFixture = (page: Page) => { + return { + reset: async () => { + await page.evaluate(() => { + delete window.E2E_CLIPBOARD_VALUE; + }); + }, + get: async () => { + return getClipboardValue({ page }); + }, + }; +}; + +function getClipboardValue({ page }: { page: Page }) { + return page.evaluate(() => { + return new Promise((resolve, reject) => { + setInterval(() => { + if (!window.E2E_CLIPBOARD_VALUE) return; + resolve(window.E2E_CLIPBOARD_VALUE); + }, 500); + setTimeout(() => reject(new Error("Timeout")), 1000); + }); + }); +} diff --git a/apps/web/playwright/fixtures/regularBookings.ts b/apps/web/playwright/fixtures/regularBookings.ts index 72c8e44fea..b0a84078e0 100644 --- a/apps/web/playwright/fixtures/regularBookings.ts +++ b/apps/web/playwright/fixtures/regularBookings.ts @@ -204,7 +204,7 @@ export function createBookingPageFixture(page: Page) { placeholder?: string ) => { 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.getByLabel("Identifier").dblclick(); await page.getByLabel("Identifier").fill(identifier); diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index 0f07d18507..b0d0a48c65 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -86,12 +86,14 @@ const createTeamAndAddUser = async ( user, isUnpublished, isOrg, + isOrgVerified, hasSubteam, organizationId, }: { - user: { id: number; username: string | null; role?: MembershipRole }; + user: { id: number; email: string; username: string | null; role?: MembershipRole }; isUnpublished?: boolean; isOrg?: boolean; + isOrgVerified?: boolean; hasSubteam?: true; organizationId?: number | null; }, @@ -103,7 +105,14 @@ const createTeamAndAddUser = async ( }; data.metadata = { ...(isUnpublished ? { requestedSlug: slug } : {}), - ...(isOrg ? { isOrganization: true } : {}), + ...(isOrg + ? { + isOrganization: true, + isOrganizationVerified: !!isOrgVerified, + orgAutoAcceptEmail: user.email.split("@")[1], + isOrganizationConfigured: false, + } + : {}), }; data.slug = !isUnpublished ? slug : undefined; if (isOrg && hasSubteam) { @@ -145,6 +154,7 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn teamEventSlug?: string; teamEventLength?: number; isOrg?: boolean; + isOrgVerified?: boolean; hasSubteam?: true; isUnpublished?: true; } = {} @@ -292,9 +302,10 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn if (scenario.hasTeam) { 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, isOrg: scenario.isOrg, + isOrgVerified: scenario.isOrgVerified, hasSubteam: scenario.hasSubteam, organizationId: opts?.organizationId, }, @@ -385,6 +396,15 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn await prisma.user.delete({ where: { id } }); store.users = store.users.filter((b) => b.id !== id); }, + 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; + }, }; }; @@ -409,7 +429,11 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => { eventTypes: user.eventTypes, routingForms: user.routingForms, 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), logout: async () => { await page.goto("/auth/logout"); diff --git a/apps/web/playwright/integrations-stripe.e2e.ts b/apps/web/playwright/integrations-stripe.e2e.ts index 25a1a33fa6..c9d86ccf0e 100644 --- a/apps/web/playwright/integrations-stripe.e2e.ts +++ b/apps/web/playwright/integrations-stripe.e2e.ts @@ -267,7 +267,7 @@ test.describe("Stripe integration", () => { await page.getByTestId("price-input-stripe").fill("200"); // 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-option-81").click(); diff --git a/apps/web/playwright/lib/fixtures.ts b/apps/web/playwright/lib/fixtures.ts index 2e54268db3..cf66ebb2f9 100644 --- a/apps/web/playwright/lib/fixtures.ts +++ b/apps/web/playwright/lib/fixtures.ts @@ -4,10 +4,12 @@ import type { API } from "mailhog"; import mailhog from "mailhog"; import { IS_MAILHOG_ENABLED } from "@calcom/lib/constants"; +import logger from "@calcom/lib/logger"; import prisma from "@calcom/prisma"; import type { ExpectedUrlDetails } from "../../../../playwright.config"; import { createBookingsFixture } from "../fixtures/bookings"; +import { createClipboardFixture } from "../fixtures/clipboard"; import { createEmbedsFixture } from "../fixtures/embeds"; import { createOrgsFixture } from "../fixtures/orgs"; import { createPaymentsFixture } from "../fixtures/payments"; @@ -28,6 +30,7 @@ export interface Fixtures { emails?: API; routingForms: ReturnType; bookingPage: ReturnType; + clipboard: ReturnType; } declare global { @@ -85,6 +88,8 @@ export const test = base.extend({ const mailhogAPI = mailhog(); await use(mailhogAPI); } 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); } }, @@ -92,4 +97,8 @@ export const test = base.extend({ const bookingPage = createBookingPageFixture(page); await use(bookingPage); }, + clipboard: async ({ page }, use) => { + const clipboard = createClipboardFixture(page); + await use(clipboard); + }, }); diff --git a/apps/web/playwright/lib/future-legacy-routes.ts b/apps/web/playwright/lib/future-legacy-routes.ts new file mode 100644 index 0000000000..d9a76b4eda --- /dev/null +++ b/apps/web/playwright/lib/future-legacy-routes.ts @@ -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); + }); + }); + }, +}; diff --git a/apps/web/playwright/lib/testUtils.ts b/apps/web/playwright/lib/testUtils.ts index b9cf3850d6..7038b656b1 100644 --- a/apps/web/playwright/lib/testUtils.ts +++ b/apps/web/playwright/lib/testUtils.ts @@ -1,11 +1,13 @@ import type { Frame, Page } from "@playwright/test"; import { expect } from "@playwright/test"; +import { createHash } from "crypto"; import EventEmitter from "events"; import type { IncomingMessage, ServerResponse } from "http"; import { createServer } from "http"; // eslint-disable-next-line no-restricted-imports import { noop } from "lodash"; import type { API, Messages } from "mailhog"; +import { totp } from "otplib"; import type { Prisma } from "@calcom/prisma/client"; import { BookingStatus } from "@calcom/prisma/enums"; @@ -278,3 +280,12 @@ export async function createUserWithSeatedEventAndAttendees( }); 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); +} diff --git a/apps/web/playwright/managed-event-types.e2e.ts b/apps/web/playwright/managed-event-types.e2e.ts index 52e6bf86c6..a0323ed8b7 100644 --- a/apps/web/playwright/managed-event-types.e2e.ts +++ b/apps/web/playwright/managed-event-types.e2e.ts @@ -1,6 +1,9 @@ import { expect } from "@playwright/test"; +import type { Page } from "@playwright/test"; import { test } from "./lib/fixtures"; +import { selectFirstAvailableTimeSlotNextMonth, bookTimeSlot } from "./lib/testUtils"; +import { localize } from "./lib/testUtils"; test.afterEach(({ users }) => users.deleteAll()); @@ -69,15 +72,34 @@ test.describe("Managed Event Types tests", () => { await page.goto("/event-types"); await page.getByTestId("event-types").locator('a[title="managed"]').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.locator('[type="submit"]').click(); 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 adminUser.logout(); + // Coming back as member user to see if there is a managed event present after assignment await memberUser.apiLogin(); 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 ?? ""); +} diff --git a/apps/web/playwright/organization/expects.ts b/apps/web/playwright/organization/expects.ts new file mode 100644 index 0000000000..e5ba1a0e83 --- /dev/null +++ b/apps/web/playwright/organization/expects.ts @@ -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"); +} diff --git a/apps/web/playwright/organization/organization-creation.e2e.ts b/apps/web/playwright/organization/organization-creation.e2e.ts new file mode 100644 index 0000000000..19b3477026 --- /dev/null +++ b/apps/web/playwright/organization/organization-creation.e2e.ts @@ -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"); + }); + }); +}); diff --git a/apps/web/playwright/organization/organization-invitation.e2e.ts b/apps/web/playwright/organization/organization-invitation.e2e.ts new file mode 100644 index 0000000000..6561a01e55 --- /dev/null +++ b/apps/web/playwright/organization/organization-invitation.e2e.ts @@ -0,0 +1,119 @@ +import { expect } from "@playwright/test"; + +import { test } from "../lib/fixtures"; +import { expectInvitationEmailToBeReceived } from "./expects"; + +test.describe.configure({ mode: "parallel" }); + +test.afterEach(async ({ users, emails, clipboard }) => { + clipboard.reset(); + await users.deleteAll(); + emails?.deleteAll(); +}); + +test.describe("Organization", () => { + test("Invitation (non verified)", async ({ browser, page, users, emails, clipboard }) => { + const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true }); + const { team: org } = await orgOwner.getOrg(); + await orgOwner.apiLogin(); + await page.goto("/settings/organizations/members"); + await page.waitForLoadState("networkidle"); + + await test.step("To the organization by email (external user)", async () => { + const invitedUserEmail = `rick@domain-${Date.now()}.com`; + await page.locator('button:text("Add")').click(); + await page.locator('input[name="inviteUser"]').fill(invitedUserEmail); + await page.locator('button:text("Send invite")').click(); + await page.waitForLoadState("networkidle"); + const inviteLink = await expectInvitationEmailToBeReceived( + page, + emails, + invitedUserEmail, + `${org.name}'s admin invited you to join the organization ${org.name} on Cal.com`, + "signup?token" + ); + + // Check newly invited member exists and is pending + await expect( + page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`) + ).toHaveCount(1); + + // eslint-disable-next-line playwright/no-conditional-in-test + if (!inviteLink) return null; + + // Follow invite link in new window + const context = await browser.newContext(); + const newPage = await context.newPage(); + newPage.goto(inviteLink); + await newPage.waitForLoadState("networkidle"); + + // Check required fields + await newPage.locator("button[type=submit]").click(); + await expect(newPage.locator(".text-red-700")).toHaveCount(3); // 3 password hints + await newPage.locator("input[name=password]").fill(`P4ssw0rd!`); + await newPage.locator("button[type=submit]").click(); + await newPage.waitForURL("/getting-started?from=signup"); + await context.close(); + await newPage.close(); + + // Check newly invited member is not pending anymore + await page.bringToFront(); + await page.goto("/settings/organizations/members"); + page.locator(`[data-testid="login-form"]`); + await expect( + page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`) + ).toHaveCount(0); + }); + + await test.step("To the organization by invite link", async () => { + // Get the invite link + await page.locator('button:text("Add")').click(); + await page.locator(`[data-testid="copy-invite-link-button"]`).click(); + const inviteLink = await clipboard.get(); + await page.waitForLoadState("networkidle"); + + // Follow invite link in new window + const context = await browser.newContext(); + const inviteLinkPage = await context.newPage(); + await inviteLinkPage.goto(inviteLink); + await inviteLinkPage.waitForLoadState("networkidle"); + + // Check required fields + await inviteLinkPage.locator("button[type=submit]").click(); + await expect(inviteLinkPage.locator(".text-red-700")).toHaveCount(4); // email + 3 password hints + + // Happy path + await inviteLinkPage.locator("input[name=email]").fill(`rick@domain-${Date.now()}.com`); + await inviteLinkPage.locator("input[name=password]").fill(`P4ssw0rd!`); + await inviteLinkPage.locator("button[type=submit]").click(); + await inviteLinkPage.waitForURL("/getting-started"); + }); + }); + + test("Invitation (verified)", async ({ browser, page, users, emails }) => { + const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true, isOrgVerified: true }); + const { team: org } = await orgOwner.getOrg(); + await orgOwner.apiLogin(); + await page.goto("/settings/organizations/members"); + await page.waitForLoadState("networkidle"); + + await test.step("To the organization by email (internal user)", async () => { + const invitedUserEmail = `rick@example.com`; + await page.locator('button:text("Add")').click(); + await page.locator('input[name="inviteUser"]').fill(invitedUserEmail); + await page.locator('button:text("Send invite")').click(); + await page.waitForLoadState("networkidle"); + await expectInvitationEmailToBeReceived( + page, + emails, + invitedUserEmail, + `${org.name}'s admin invited you to join the organization ${org.name} on Cal.com` + ); + + // Check newly invited member exists and is pending + await expect( + page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`) + ).toHaveCount(0); + }); + }); +}); diff --git a/apps/web/playwright/payment-apps.e2e.ts b/apps/web/playwright/payment-apps.e2e.ts index c01bc10ba2..77bf674d92 100644 --- a/apps/web/playwright/payment-apps.e2e.ts +++ b/apps/web/playwright/payment-apps.e2e.ts @@ -77,7 +77,7 @@ test.describe("Payment app", () => { await page.goto(`event-types/${paymentEvent.id}?tabName=apps`); await page.locator("#event-type-form").getByRole("switch").click(); - await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click(); + await page.getByTestId("stripe-currency-select").click(); await page.getByTestId("select-option-usd").click(); await page.getByTestId("price-input-stripe").click(); @@ -123,10 +123,10 @@ test.describe("Payment app", () => { await page.getByPlaceholder("Price").click(); await page.getByPlaceholder("Price").fill("150"); - await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click(); + await page.getByTestId("paypal-currency-select").click(); await page.locator("#react-select-2-option-13").click(); - await page.locator(".mb-1 > .bg-default > div > div:nth-child(2)").first().click(); + await page.getByTestId("paypal-payment-option-select").click(); await page.getByText("$MXNCurrencyMexican pesoPayment option").click(); await page.getByTestId("update-eventtype").click(); diff --git a/apps/web/playwright/team/expects.ts b/apps/web/playwright/team/expects.ts new file mode 100644 index 0000000000..43e02063f6 --- /dev/null +++ b/apps/web/playwright/team/expects.ts @@ -0,0 +1,29 @@ +import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; +import { JSDOM } from "jsdom"; +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; + + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(10000); + 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"); +} diff --git a/apps/web/playwright/team/team-invitation.e2e.ts b/apps/web/playwright/team/team-invitation.e2e.ts new file mode 100644 index 0000000000..95505bf279 --- /dev/null +++ b/apps/web/playwright/team/team-invitation.e2e.ts @@ -0,0 +1,124 @@ +import { expect } from "@playwright/test"; + +import { WEBAPP_URL } from "@calcom/lib/constants"; + +import { test } from "../lib/fixtures"; +import { localize } from "../lib/testUtils"; +import { expectInvitationEmailToBeReceived } from "./expects"; + +test.describe.configure({ mode: "parallel" }); + +test.afterEach(async ({ users, emails, clipboard }) => { + clipboard.reset(); + await users.deleteAll(); + emails?.deleteAll(); +}); + +test.describe("Team", () => { + test("Invitation (non verified)", async ({ browser, page, users, emails, clipboard }) => { + const t = await localize("en"); + const teamOwner = await users.create(undefined, { hasTeam: true }); + const { team } = await teamOwner.getFirstTeam(); + await teamOwner.apiLogin(); + await page.goto(`/settings/teams/${team.id}/members`); + await page.waitForLoadState("networkidle"); + + await test.step("To the team by email (external user)", async () => { + const invitedUserEmail = `rick_${Date.now()}@domain-${Date.now()}.com`; + await page.locator(`button:text("${t("add")}")`).click(); + await page.locator('input[name="inviteUser"]').fill(invitedUserEmail); + await page.locator(`button:text("${t("send_invite")}")`).click(); + await page.waitForLoadState("networkidle"); + const inviteLink = await expectInvitationEmailToBeReceived( + page, + emails, + invitedUserEmail, + `${team.name}'s admin invited you to join the team ${team.name} on Cal.com`, + "signup?token" + ); + + //Check newly invited member exists and is pending + await expect( + page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`) + ).toHaveCount(1); + + // eslint-disable-next-line playwright/no-conditional-in-test + if (!inviteLink) return null; + + // Follow invite link to new window + const context = await browser.newContext(); + const newPage = await context.newPage(); + await newPage.goto(inviteLink); + await newPage.waitForLoadState("networkidle"); + + // Check required fields + await newPage.locator("button[type=submit]").click(); + await expect(newPage.locator('[data-testid="hint-error"]')).toHaveCount(3); + await newPage.locator("input[name=password]").fill(`P4ssw0rd!`); + await newPage.locator("button[type=submit]").click(); + await newPage.waitForURL("/getting-started?from=signup"); + await newPage.close(); + await context.close(); + + // Check newly invited member is not pending anymore + await page.bringToFront(); + await page.goto(`/settings/teams/${team.id}/members`); + await page.waitForLoadState("networkidle"); + await expect( + page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`) + ).toHaveCount(0); + }); + + await test.step("To the team by invite link", async () => { + const user = await users.create({ + email: `user-invite-${Date.now()}@domain.com`, + password: "P4ssw0rd!", + }); + await page.locator(`button:text("${t("add")}")`).click(); + await page.locator(`[data-testid="copy-invite-link-button"]`).click(); + const inviteLink = await clipboard.get(); + await page.waitForLoadState("networkidle"); + + const context = await browser.newContext(); + const inviteLinkPage = await context.newPage(); + await inviteLinkPage.goto(inviteLink); + await inviteLinkPage.waitForLoadState("domcontentloaded"); + + await inviteLinkPage.locator("button[type=submit]").click(); + await expect(inviteLinkPage.locator('[data-testid="field-error"]')).toHaveCount(2); + + await inviteLinkPage.locator("input[name=email]").fill(user.email); + await inviteLinkPage.locator("input[name=password]").fill(user.username || "P4ssw0rd!"); + await inviteLinkPage.locator("button[type=submit]").click(); + + await inviteLinkPage.waitForURL(`${WEBAPP_URL}/teams**`); + }); + }); + + test("Invitation (verified)", async ({ browser, page, users, emails }) => { + const t = await localize("en"); + const teamOwner = await users.create({ name: `team-owner-${Date.now()}` }, { hasTeam: true }); + const { team } = await teamOwner.getFirstTeam(); + await teamOwner.apiLogin(); + await page.goto(`/settings/teams/${team.id}/members`); + await page.waitForLoadState("networkidle"); + + await test.step("To the organization by email (internal user)", async () => { + const invitedUserEmail = `rick@example.com`; + await page.locator(`button:text("${t("add")}")`).click(); + await page.locator('input[name="inviteUser"]').fill(invitedUserEmail); + await page.locator(`button:text("${t("send_invite")}")`).click(); + await page.waitForLoadState("networkidle"); + await expectInvitationEmailToBeReceived( + page, + emails, + invitedUserEmail, + `${teamOwner.name} invited you to join the team ${team.name} on Cal.com` + ); + + await expect( + page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`) + ).toHaveCount(1); + }); + }); +}); diff --git a/apps/web/playwright/teams.e2e.ts b/apps/web/playwright/teams.e2e.ts index 0914cc4eb8..ae0c11d821 100644 --- a/apps/web/playwright/teams.e2e.ts +++ b/apps/web/playwright/teams.e2e.ts @@ -351,7 +351,7 @@ test.describe("Teams - Org", () => { await page.goto(`/team/${team.slug}/${teamEventSlug}`); - await expect(page.locator('[data-testid="404-page"]')).toBeVisible(); + await expect(page.locator("text=This page could not be found")).toBeVisible(); await doOnOrgDomain( { orgSlug: org.slug, diff --git a/apps/web/public/static/locales/fr/common.json b/apps/web/public/static/locales/fr/common.json index 49dc72ca82..0bf2d447f7 100644 --- a/apps/web/public/static/locales/fr/common.json +++ b/apps/web/public/static/locales/fr/common.json @@ -1098,6 +1098,7 @@ "developer_documentation": "Documentation pour développeurs", "get_in_touch": "Contactez-nous", "contact_support": "Contacter l'assistance", + "premium_support": "Assistance Premium", "community_support": "Aide communautaire", "feedback": "Commentaires", "submitted_feedback": "Merci pour vos commentaires !", diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index ca5be263cd..831bb9eb69 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -5,6 +5,7 @@ "paths": { "~/*": ["modules/*"], "@components/*": ["components/*"], + "@pages/*": ["pages/*"], "@lib/*": ["lib/*"], "@server/*": ["server/*"], "@prisma/client/*": ["@calcom/prisma/client/*"] diff --git a/package.json b/package.json index 4f74854f79..586870f28e 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "@playwright/test": "^1.31.2", "@snaplet/copycat": "^0.3.0", "@testing-library/jest-dom": "^5.16.5", + "@types/jsdom": "^21.1.3", "@types/jsonwebtoken": "^9.0.3", "c8": "^7.13.0", "checkly": "latest", diff --git a/packages/app-store/_utils/paid-apps.ts b/packages/app-store/_utils/paid-apps.ts index 6d39c98ff6..1217831a45 100644 --- a/packages/app-store/_utils/paid-apps.ts +++ b/packages/app-store/_utils/paid-apps.ts @@ -40,6 +40,8 @@ export const withPaidAppRedirect = async ({ ? { subscription_data: { trial_period_days: trialDays, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - trial_settings isn't available cc @erik trial_settings: { end_behavior: { missing_payment_method: "cancel" } }, }, } diff --git a/packages/app-store/alby/lib/PaymentService.ts b/packages/app-store/alby/lib/PaymentService.ts index 9974f1aa25..c29b08427a 100644 --- a/packages/app-store/alby/lib/PaymentService.ts +++ b/packages/app-store/alby/lib/PaymentService.ts @@ -72,7 +72,10 @@ export class PaymentService implements IAbstractPaymentService { amount: payment.amount, externalId: invoice.paymentRequest, currency: payment.currency, - data: Object.assign({}, { invoice }) as unknown as Prisma.InputJsonValue, + data: Object.assign( + {}, + { invoice: { ...invoice, isPaid: false } } + ) as unknown as Prisma.InputJsonValue, fee: 0, refunded: false, success: false, @@ -84,7 +87,7 @@ export class PaymentService implements IAbstractPaymentService { } return paymentData; } catch (error) { - log.error("Alby: Payment could not be created", bookingId); + log.error("Alby: Payment could not be created", bookingId, JSON.stringify(error)); throw new Error(ErrorCode.PaymentCreationFailure); } } diff --git a/packages/app-store/cal-ai/config.json b/packages/app-store/cal-ai/config.json index e6718b7b5d..6ec6551057 100644 --- a/packages/app-store/cal-ai/config.json +++ b/packages/app-store/cal-ai/config.json @@ -15,7 +15,7 @@ "__template": "basic", "dirName": "cal-ai", "paid": { - "priceInUsd": 25, + "priceInUsd": 8, "priceId": "price_1O1ziDH8UDiwIftkDHp3MCTP", "mode": "subscription" } diff --git a/packages/app-store/googlecalendar/api/add.ts b/packages/app-store/googlecalendar/api/add.ts index 3a32c968fa..7ed6fcf02d 100644 --- a/packages/app-store/googlecalendar/api/add.ts +++ b/packages/app-store/googlecalendar/api/add.ts @@ -2,6 +2,8 @@ import { google } from "googleapis"; import type { NextApiRequest, NextApiResponse } from "next"; import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants"; +import { HttpError } from "@calcom/lib/http-error"; +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState"; @@ -14,28 +16,30 @@ const scopes = [ let client_id = ""; let client_secret = ""; -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method === "GET") { - // Get token from Google Calendar API - const appKeys = await getAppKeysFromSlug("google-calendar"); - if (typeof appKeys.client_id === "string") client_id = appKeys.client_id; - if (typeof appKeys.client_secret === "string") client_secret = appKeys.client_secret; - if (!client_id) return res.status(400).json({ message: "Google client_id missing." }); - if (!client_secret) return res.status(400).json({ message: "Google client_secret missing." }); - const redirect_uri = `${WEBAPP_URL_FOR_OAUTH}/api/integrations/googlecalendar/callback`; - const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri); +async function getHandler(req: NextApiRequest, res: NextApiResponse) { + // Get token from Google Calendar API + const appKeys = await getAppKeysFromSlug("google-calendar"); + if (typeof appKeys.client_id === "string") client_id = appKeys.client_id; + if (typeof appKeys.client_secret === "string") client_secret = appKeys.client_secret; + if (!client_id) throw new HttpError({ statusCode: 400, message: "Google client_id missing." }); + if (!client_secret) throw new HttpError({ statusCode: 400, message: "Google client_secret missing." }); + const redirect_uri = `${WEBAPP_URL_FOR_OAUTH}/api/integrations/googlecalendar/callback`; + const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri); - const authUrl = oAuth2Client.generateAuthUrl({ - access_type: "offline", - scope: scopes, - // A refresh token is only returned the first time the user - // consents to providing access. For illustration purposes, - // setting the prompt to 'consent' will force this consent - // every time, forcing a refresh_token to be returned. - prompt: "consent", - state: encodeOAuthState(req), - }); + const authUrl = oAuth2Client.generateAuthUrl({ + access_type: "offline", + scope: scopes, + // A refresh token is only returned the first time the user + // consents to providing access. For illustration purposes, + // setting the prompt to 'consent' will force this consent + // every time, forcing a refresh_token to be returned. + prompt: "consent", + state: encodeOAuthState(req), + }); - res.status(200).json({ url: authUrl }); - } + res.status(200).json({ url: authUrl }); } + +export default defaultHandler({ + GET: Promise.resolve({ default: defaultResponder(getHandler) }), +}); diff --git a/packages/app-store/googlecalendar/api/callback.ts b/packages/app-store/googlecalendar/api/callback.ts index 2b3d2d90b0..3577e9b092 100644 --- a/packages/app-store/googlecalendar/api/callback.ts +++ b/packages/app-store/googlecalendar/api/callback.ts @@ -3,6 +3,8 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { WEBAPP_URL_FOR_OAUTH, CAL_URL } from "@calcom/lib/constants"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; +import { HttpError } from "@calcom/lib/http-error"; +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; import prisma from "@calcom/prisma"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; @@ -12,24 +14,23 @@ import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState"; let client_id = ""; let client_secret = ""; -export default async function handler(req: NextApiRequest, res: NextApiResponse) { +async function getHandler(req: NextApiRequest, res: NextApiResponse) { const { code } = req.query; const state = decodeOAuthState(req); if (typeof code !== "string") { - res.status(400).json({ message: "`code` must be a string" }); - return; + throw new HttpError({ statusCode: 400, message: "`code` must be a string" }); } if (!req.session?.user?.id) { - return res.status(401).json({ message: "You must be logged in to do this" }); + throw new HttpError({ statusCode: 401, message: "You must be logged in to do this" }); } const appKeys = await getAppKeysFromSlug("google-calendar"); if (typeof appKeys.client_id === "string") client_id = appKeys.client_id; if (typeof appKeys.client_secret === "string") client_secret = appKeys.client_secret; - if (!client_id) return res.status(400).json({ message: "Google client_id missing." }); - if (!client_secret) return res.status(400).json({ message: "Google client_secret missing." }); + if (!client_id) throw new HttpError({ statusCode: 400, message: "Google client_id missing." }); + if (!client_secret) throw new HttpError({ statusCode: 400, message: "Google client_secret missing." }); const redirect_uri = `${WEBAPP_URL_FOR_OAUTH}/api/integrations/googlecalendar/callback`; @@ -107,3 +108,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) getInstalledAppPath({ variant: "calendar", slug: "google-calendar" }) ); } + +export default defaultHandler({ + GET: Promise.resolve({ default: defaultResponder(getHandler) }), +}); diff --git a/packages/app-store/googlecalendar/lib/CalendarService.test.ts b/packages/app-store/googlecalendar/lib/CalendarService.test.ts index 8a416ea6eb..8cf8f5b247 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.test.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.test.ts @@ -78,17 +78,15 @@ test("Calendar Cache is being called", async () => { // prismaMock.calendarCache.create.mock. const calendarService = new CalendarService(testCredential); - // @ts-expect-error authedCalendar is a private method, hence the TS error - vi.spyOn(calendarService, "authedCalendar").mockReturnValue( - // @ts-expect-error trust me bro - { - freebusy: { - query: vi.fn().mockReturnValue({ - data: testFreeBusyResponse, - }), - }, - } - ); + vi.spyOn(calendarService, "authedCalendar").mockReturnValue({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore - Mocking the authedCalendar so can't return the actual response + freebusy: { + query: vi.fn().mockReturnValue({ + data: testFreeBusyResponse, + }), + }, + }); await calendarService.getAvailability(new Date().toISOString(), new Date().toISOString(), [ testSelectedCalendar, diff --git a/packages/app-store/googlecalendar/lib/CalendarService.ts b/packages/app-store/googlecalendar/lib/CalendarService.ts index f3af3a9cff..e01982378b 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.ts @@ -132,7 +132,7 @@ export default class GoogleCalendarService implements Calendar { }; }; - private authedCalendar = async () => { + public authedCalendar = async () => { const myGoogleAuth = await this.auth.getToken(); const calendar = google.calendar({ version: "v3", diff --git a/packages/app-store/googlecalendar/tests/google-calendar.e2e.ts b/packages/app-store/googlecalendar/tests/google-calendar.e2e.ts new file mode 100644 index 0000000000..226b7a61cd --- /dev/null +++ b/packages/app-store/googlecalendar/tests/google-calendar.e2e.ts @@ -0,0 +1,215 @@ +import { expect } from "@playwright/test"; +import type { Page } from "@playwright/test"; + +import dayjs from "@calcom/dayjs"; +import { APP_CREDENTIAL_SHARING_ENABLED } from "@calcom/lib/constants"; +import prisma from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; +import { test } from "@calcom/web/playwright/lib/fixtures"; +import { selectSecondAvailableTimeSlotNextMonth } from "@calcom/web/playwright/lib/testUtils"; + +import metadata from "../_metadata"; +import GoogleCalendarService from "../lib/CalendarService"; +import { createBookingAndFetchGCalEvent, deleteBookingAndEvent, assertValueExists } from "./testUtils"; + +test.describe("Google Calendar", async () => { + test.describe("Test using the primary calendar", async () => { + let qaUsername: string; + let qaGCalCredential: Prisma.CredentialGetPayload<{ select: { id: true } }>; + test.beforeAll(async () => { + let runIntegrationTest = false; + + test.skip(!!APP_CREDENTIAL_SHARING_ENABLED, "Credential sharing enabled"); + + if (process.env.E2E_TEST_CALCOM_QA_EMAIL && process.env.E2E_TEST_CALCOM_QA_PASSWORD) { + qaGCalCredential = await prisma.credential.findFirstOrThrow({ + where: { + user: { + email: process.env.E2E_TEST_CALCOM_QA_EMAIL, + }, + type: metadata.type, + }, + select: { + id: true, + }, + }); + + const qaUserQuery = await prisma.user.findFirstOrThrow({ + where: { + email: process.env.E2E_TEST_CALCOM_QA_EMAIL, + }, + select: { + username: true, + }, + }); + + assertValueExists(qaUserQuery.username, "qaUsername"); + qaUsername = qaUserQuery.username; + + if (qaGCalCredential && qaUsername) runIntegrationTest = true; + } + + test.skip(!runIntegrationTest, "QA user not found"); + }); + + test.beforeEach(async ({ page, users }) => { + assertValueExists(process.env.E2E_TEST_CALCOM_QA_EMAIL, "qaEmail"); + + const qaUserStore = await users.set(process.env.E2E_TEST_CALCOM_QA_EMAIL); + + await qaUserStore.apiLogin(process.env.E2E_TEST_CALCOM_QA_PASSWORD); + + // Need to refresh keys from DB + const refreshedCredential = await prisma.credential.findFirst({ + where: { + id: qaGCalCredential?.id, + }, + include: { + user: { + select: { + email: true, + }, + }, + }, + }); + assertValueExists(refreshedCredential, "refreshedCredential"); + + const googleCalendarService = new GoogleCalendarService(refreshedCredential); + + const calendars = await googleCalendarService.listCalendars(); + + const primaryCalendarName = calendars.find((calendar) => calendar.primary)?.name; + assertValueExists(primaryCalendarName, "primaryCalendarName"); + + await page.goto("/apps/installed/calendar"); + + await page.waitForSelector('[title*="Create events on"]'); + await page.locator('[title*="Create events on"]').locator("svg").click(); + await page.locator("#react-select-2-option-0-0").getByText(primaryCalendarName).click(); + }); + + test("On new booking, event should be created on GCal", async ({ page }) => { + const { gCalEvent, gCalReference, booking, authedCalendar } = await createBookingAndFetchGCalEvent( + page as Page, + qaGCalCredential, + qaUsername + ); + + assertValueExists(gCalEvent.start?.timeZone, "gCalEvent"); + assertValueExists(gCalEvent.end?.timeZone, "gCalEvent"); + + // Ensure that the start and end times are matching + const startTimeMatches = dayjs(booking.startTime).isSame( + dayjs(gCalEvent.start.dateTime).tz(gCalEvent.start.timeZone) + ); + const endTimeMatches = dayjs(booking.endTime).isSame( + dayjs(gCalEvent.end?.dateTime).tz(gCalEvent.end.timeZone) + ); + expect(startTimeMatches && endTimeMatches).toBe(true); + + // Ensure that the titles are matching + expect(booking.title).toBe(gCalEvent.summary); + + // Ensure that the attendee is on the event + const bookingAttendee = booking?.attendees[0].email; + const attendeeInGCalEvent = gCalEvent.attendees?.find((attendee) => attendee.email === bookingAttendee); + expect(attendeeInGCalEvent).toBeTruthy(); + + await deleteBookingAndEvent(authedCalendar, booking.uid, gCalReference.uid); + }); + + test("On reschedule, event should be updated on GCal", async ({ page }) => { + // Reschedule the booking and check the gCalEvent's time is also changed + // On reschedule gCal UID stays the same + const { gCalReference, booking, authedCalendar } = await createBookingAndFetchGCalEvent( + page, + qaGCalCredential, + qaUsername + ); + + await page.locator('[data-testid="reschedule-link"]').click(); + + await selectSecondAvailableTimeSlotNextMonth(page); + await page.locator('[data-testid="confirm-reschedule-button"]').click(); + + await expect(page.locator("[data-testid=success-page]")).toBeVisible(); + + const rescheduledBookingUrl = await page.url(); + const rescheduledBookingUid = rescheduledBookingUrl.match(/booking\/([^\/?]+)/); + + assertValueExists(rescheduledBookingUid, "rescheduledBookingUid"); + + // Get the rescheduled booking start and end times + const rescheduledBooking = await prisma.booking.findFirst({ + where: { + uid: rescheduledBookingUid[1], + }, + select: { + startTime: true, + endTime: true, + }, + }); + assertValueExists(rescheduledBooking, "rescheduledBooking"); + + // The GCal event UID persists after reschedule but should get the rescheduled data + const gCalRescheduledEventResponse = await authedCalendar.events.get({ + calendarId: "primary", + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + eventId: gCalReference.uid, + }); + + expect(gCalRescheduledEventResponse.status).toBe(200); + + const rescheduledGCalEvent = gCalRescheduledEventResponse.data; + + assertValueExists(rescheduledGCalEvent.start?.timeZone, "rescheduledGCalEvent"); + assertValueExists(rescheduledGCalEvent.end?.timeZone, "rescheduledGCalEvent"); + + // Ensure that the new start and end times are matching + const rescheduledStartTimeMatches = dayjs(rescheduledBooking.startTime).isSame( + dayjs(rescheduledGCalEvent.start?.dateTime).tz(rescheduledGCalEvent.start?.timeZone) + ); + const rescheduledEndTimeMatches = dayjs(rescheduledBooking.endTime).isSame( + dayjs(rescheduledGCalEvent.end?.dateTime).tz(rescheduledGCalEvent.end.timeZone) + ); + expect(rescheduledStartTimeMatches && rescheduledEndTimeMatches).toBe(true); + + // After test passes we can delete the bookings and GCal event + await deleteBookingAndEvent(authedCalendar, booking.uid, gCalReference.uid); + + await prisma.booking.delete({ + where: { + uid: rescheduledBookingUid[1], + }, + }); + }); + + test("When canceling the booking, the GCal event should also be deleted", async ({ page }) => { + const { gCalReference, booking, authedCalendar } = await createBookingAndFetchGCalEvent( + page, + qaGCalCredential, + qaUsername + ); + + // Cancel the booking + await page.locator('[data-testid="cancel"]').click(); + await page.locator('[data-testid="confirm_cancel"]').click(); + // Query for the bookingUID and ensure that it doesn't exist on GCal + + await page.waitForSelector('[data-testid="cancelled-headline"]'); + + const canceledGCalEventResponse = await authedCalendar.events.get({ + calendarId: "primary", + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + eventId: gCalReference.uid, + }); + + expect(canceledGCalEventResponse.data.status).toBe("cancelled"); + + // GCal API sees canceled events as already deleted + await deleteBookingAndEvent(authedCalendar, booking.uid); + }); + }); +}); diff --git a/packages/app-store/googlecalendar/tests/testUtils.ts b/packages/app-store/googlecalendar/tests/testUtils.ts new file mode 100644 index 0000000000..5d4920d2b1 --- /dev/null +++ b/packages/app-store/googlecalendar/tests/testUtils.ts @@ -0,0 +1,127 @@ +import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; + +import prisma from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; +import { bookFirstEvent } from "@calcom/web/playwright/lib/testUtils"; + +import metadata from "../_metadata"; +import GoogleCalendarService from "../lib/CalendarService"; + +/** + * Creates the booking on Cal.com and makes the GCal call to fetch the event. + * Ends on the booking success page + * @param page + * + * @returns the raw GCal event GET response and the booking reference + */ +export const createBookingAndFetchGCalEvent = async ( + page: Page, + qaGCalCredential: Prisma.CredentialGetPayload<{ select: { id: true } }> | null, + qaUsername: string +) => { + await page.goto(`/${qaUsername}`); + await bookFirstEvent(page); + + const bookingUrl = await page.url(); + const bookingUid = bookingUrl.match(/booking\/([^\/?]+)/); + assertValueExists(bookingUid, "bookingUid"); + + const [gCalReference, booking] = await Promise.all([ + prisma.bookingReference.findFirst({ + where: { + booking: { + uid: bookingUid[1], + }, + type: metadata.type, + credentialId: qaGCalCredential?.id, + }, + select: { + uid: true, + booking: {}, + }, + }), + prisma.booking.findFirst({ + where: { + uid: bookingUid[1], + }, + select: { + uid: true, + startTime: true, + endTime: true, + title: true, + attendees: { + select: { + email: true, + }, + }, + user: { + select: { + email: true, + }, + }, + }, + }), + ]); + assertValueExists(gCalReference, "gCalReference"); + assertValueExists(booking, "booking"); + + // Need to refresh keys from DB + const refreshedCredential = await prisma.credential.findFirst({ + where: { + id: qaGCalCredential?.id, + }, + include: { + user: { + select: { + email: true, + }, + }, + }, + }); + + expect(refreshedCredential).toBeTruthy(); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + const googleCalendarService = new GoogleCalendarService(refreshedCredential); + + const authedCalendar = await googleCalendarService.authedCalendar(); + + const gCalEventResponse = await authedCalendar.events.get({ + calendarId: "primary", + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + eventId: gCalReference.uid, + }); + + expect(gCalEventResponse.status).toBe(200); + + return { gCalEvent: gCalEventResponse.data, gCalReference, booking, authedCalendar }; +}; + +export const deleteBookingAndEvent = async ( + authedCalendar: any, + bookingUid: string, + gCalReferenceUid?: string +) => { + // After test passes we can delete the booking and GCal event + await prisma.booking.delete({ + where: { + uid: bookingUid, + }, + }); + + if (gCalReferenceUid) { + await authedCalendar.events.delete({ + calendarId: "primary", + eventId: gCalReferenceUid, + }); + } +}; + +export function assertValueExists(value: unknown, variableName?: string): asserts value { + if (!value) { + throw new Error(`Value is not defined: ${variableName}`); + } +} diff --git a/packages/app-store/office365calendar/api/add.ts b/packages/app-store/office365calendar/api/add.ts index 60e06d18b1..e087eab78a 100644 --- a/packages/app-store/office365calendar/api/add.ts +++ b/packages/app-store/office365calendar/api/add.ts @@ -20,6 +20,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) response_type: "code", scope: scopes.join(" "), client_id, + prompt: "select_account", redirect_uri: `${WEBAPP_URL}/api/integrations/office365calendar/callback`, state, }; diff --git a/packages/app-store/package.json b/packages/app-store/package.json index 6cfd20e06a..62225f2b65 100644 --- a/packages/app-store/package.json +++ b/packages/app-store/package.json @@ -26,7 +26,7 @@ "lodash": "^4.17.21", "qs-stringify": "^1.2.1", "react-i18next": "^12.2.0", - "stripe": "^14.3.0" + "stripe": "^9.16.0" }, "devDependencies": { "@calcom/types": "*" diff --git a/packages/app-store/paypal/components/EventTypeAppCardInterface.tsx b/packages/app-store/paypal/components/EventTypeAppCardInterface.tsx index 536d159652..db6ba04755 100644 --- a/packages/app-store/paypal/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/paypal/components/EventTypeAppCardInterface.tsx @@ -92,6 +92,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ + data-testid="stripe-payment-option-select" defaultValue={ paymentOptionSelectValue ? { ...paymentOptionSelectValue, label: t(paymentOptionSelectValue.label) } diff --git a/packages/config/package.json b/packages/config/package.json index d5e92004df..14d083319c 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -31,7 +31,7 @@ "prettier": "^2.8.6", "prettier-plugin-tailwindcss": "^0.2.5", "tailwind-scrollbar": "^2.0.1", - "tailwindcss": "^3.3.1", + "tailwindcss": "^3.3.3", "typescript": "^4.9.4" } } diff --git a/packages/core/builders/CalendarEvent/class.ts b/packages/core/builders/CalendarEvent/class.ts index 2b33d7223d..8a449dbfd2 100644 --- a/packages/core/builders/CalendarEvent/class.ts +++ b/packages/core/builders/CalendarEvent/class.ts @@ -17,7 +17,7 @@ class CalendarEventClass implements CalendarEvent { organizer!: Person; attendees!: Person[]; description?: string | null; - team?: { name: string; members: Person[] }; + team?: { name: string; members: Person[]; id: number }; location?: string | null; conferenceData?: ConferenceData; additionalInformation?: AdditionalInformation; diff --git a/packages/embeds/embed-core/package.json b/packages/embeds/embed-core/package.json index 222ee9d749..835b5230c9 100644 --- a/packages/embeds/embed-core/package.json +++ b/packages/embeds/embed-core/package.json @@ -48,7 +48,7 @@ "autoprefixer": "^10.4.12", "npm-run-all": "^4.1.5", "postcss": "^8.4.18", - "tailwindcss": "^3.3.1", + "tailwindcss": "^3.3.3", "typescript": "^4.9.4", "vite": "^4.1.2" } diff --git a/packages/features/MainLayoutAppDir.tsx b/packages/features/MainLayoutAppDir.tsx new file mode 100644 index 0000000000..acf4a2b522 --- /dev/null +++ b/packages/features/MainLayoutAppDir.tsx @@ -0,0 +1,19 @@ +"use client"; + +import type { ComponentProps } from "react"; +import React from "react"; + +import Shell from "@calcom/features/shell/Shell"; + +export default function MainLayout({ + children, + ...rest +}: { children: React.ReactNode } & ComponentProps) { + return ( + + {children} + + ); +} + +export const getLayout = (page: React.ReactElement) => {page}; diff --git a/packages/features/auth/lib/getLocale.ts b/packages/features/auth/lib/getLocale.ts index 6e6c2795a6..1ea67ffafd 100644 --- a/packages/features/auth/lib/getLocale.ts +++ b/packages/features/auth/lib/getLocale.ts @@ -2,9 +2,11 @@ import { parse } from "accept-language-parser"; import { lookup } from "bcp-47-match"; import type { GetTokenParams } from "next-auth/jwt"; import { getToken } from "next-auth/jwt"; +import { type ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers"; +import { type ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies"; //@ts-expect-error no type definitions -import { i18n } from "@calcom/web/next-i18next.config"; +import { i18n } from "@calcom/config/next-i18next.config"; /** * This is a slimmed down version of the `getServerSession` function from @@ -17,9 +19,16 @@ import { i18n } from "@calcom/web/next-i18next.config"; * token has expired (30 days). This should be fine as we call `/auth/session` * frequently enough on the client-side to keep the session alive. */ -export const getLocale = async (req: GetTokenParams["req"]): Promise => { +export const getLocale = async ( + req: + | GetTokenParams["req"] + | { + cookies: ReadonlyRequestCookies; + headers: ReadonlyHeaders; + } +): Promise => { const token = await getToken({ - req, + req: req as GetTokenParams["req"], }); const tokenLocale = token?.["locale"]; diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index 217972f873..b71c9aa06d 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -72,7 +72,12 @@ async function getBookingToDelete(id: number | undefined, uid: string | undefine hideBranding: true, }, }, - teamId: true, + team: { + select: { + id: true, + name: true, + }, + }, recurringEvent: true, title: true, eventName: true, @@ -151,11 +156,10 @@ async function handler(req: CustomRequest) { const teamId = await getTeamIdFromEventType({ eventType: { - team: { id: bookingToDelete.eventType?.teamId ?? null }, + team: { id: bookingToDelete.eventType?.team?.id ?? null }, parentId: bookingToDelete?.eventType?.parentId ?? null, }, }); - const triggerForUser = !teamId || (teamId && bookingToDelete.eventType?.parentId); const subscriberOptions = { @@ -227,6 +231,7 @@ async function handler(req: CustomRequest) { type: bookingToDelete?.eventType?.slug as string, description: bookingToDelete?.description || "", customInputs: isPrismaObjOrUndefined(bookingToDelete.customInputs), + eventTypeId: bookingToDelete.eventTypeId as number, ...getCalEventResponses({ bookingFields: bookingToDelete.eventType?.bookingFields ?? null, booking: bookingToDelete, @@ -254,7 +259,9 @@ async function handler(req: CustomRequest) { ? [bookingToDelete?.user.destinationCalendar] : [], cancellationReason: cancellationReason, - ...(teamMembers && { team: { name: "", members: teamMembers } }), + ...(teamMembers && { + team: { name: bookingToDelete?.eventType?.team?.name || "Nameless", members: teamMembers, id: teamId! }, + }), seatsPerTimeSlot: bookingToDelete.eventType?.seatsPerTimeSlot, seatsShowAttendees: bookingToDelete.eventType?.seatsShowAttendees, }; @@ -407,7 +414,7 @@ async function handler(req: CustomRequest) { if (bookingToDelete.location === DailyLocationType) { bookingToDelete.user.credentials.push({ ...FAKE_DAILY_CREDENTIAL, - teamId: bookingToDelete.eventType?.teamId || null, + teamId: bookingToDelete.eventType?.team?.id || null, }); } @@ -539,10 +546,10 @@ async function handler(req: CustomRequest) { let eventTypeOwnerId; if (bookingToDelete.eventType?.owner) { eventTypeOwnerId = bookingToDelete.eventType.owner.id; - } else if (bookingToDelete.eventType?.teamId) { + } else if (bookingToDelete.eventType?.team?.id) { const teamOwner = await prisma.membership.findFirst({ where: { - teamId: bookingToDelete.eventType.teamId, + teamId: bookingToDelete.eventType?.team.id, role: MembershipRole.OWNER, }, select: { diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 2d73a22145..dd00afc82c 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -1001,8 +1001,11 @@ async function handler( const attendeeTimezone = attendeeInfoOnReschedule ? attendeeInfoOnReschedule.timeZone : reqBody.timeZone; const tAttendees = await getTranslation(attendeeLanguage ?? "en", "common"); + + const isManagedEventType = !!eventType.parentId; + // use host default - if (isTeamEventType && locationBodyString === OrganizerDefaultConferencingAppType) { + if ((isManagedEventType || isTeamEventType) && locationBodyString === OrganizerDefaultConferencingAppType) { const metadataParseResult = userMetadataSchema.safeParse(organizerUser.metadata); const organizerMetadata = metadataParseResult.success ? metadataParseResult.data : undefined; if (organizerMetadata?.defaultConferencingApp?.appSlug) { @@ -1076,7 +1079,6 @@ async function handler( }, }; }); - const teamMembers = await Promise.all(teamMemberPromises); const attendeesList = [...invitee, ...guests]; @@ -1884,6 +1886,7 @@ async function handler( evt.team = { members: teamMembers, name: eventType.team?.name || "Nameless", + id: eventType.team?.id ?? 0, }; } diff --git a/packages/features/ee/organizations/pages/settings/members.tsx b/packages/features/ee/organizations/pages/settings/members.tsx index 36ba6b64c5..9f40c42a57 100644 --- a/packages/features/ee/organizations/pages/settings/members.tsx +++ b/packages/features/ee/organizations/pages/settings/members.tsx @@ -11,24 +11,6 @@ const MembersView = () => {
- {/* {team && ( - <> - {isInviteOpen && ( - - )} - - )} */}
diff --git a/packages/features/ee/teams/components/MemberInvitationModal.tsx b/packages/features/ee/teams/components/MemberInvitationModal.tsx index 4aeffb573c..fc8fcf90fc 100644 --- a/packages/features/ee/teams/components/MemberInvitationModal.tsx +++ b/packages/features/ee/teams/components/MemberInvitationModal.tsx @@ -25,6 +25,7 @@ import { TextAreaField, } from "@calcom/ui"; import { Link } from "@calcom/ui/components/icon"; +import type { Window as WindowWithClipboardValue } from "@calcom/web/playwright/fixtures/clipboard"; import type { PendingMember } from "../lib/types"; import { GoogleWorkspaceInviteButton } from "./GoogleWorkspaceInviteButton"; @@ -92,8 +93,15 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps) const inviteLink = isOrgInvite || (props?.orgMembers && props.orgMembers?.length > 0) ? orgInviteLink : teamInviteLink; - await navigator.clipboard.writeText(inviteLink); - showToast(t("invite_link_copied"), "success"); + try { + await navigator.clipboard.writeText(inviteLink); + showToast(t("invite_link_copied"), "success"); + } catch (e) { + if (process.env.NEXT_PUBLIC_IS_E2E) { + (window as WindowWithClipboardValue).E2E_CLIPBOARD_VALUE = inviteLink; + } + console.error(e); + } }; const options: MembershipRoleOption[] = useMemo(() => { diff --git a/packages/features/ee/teams/components/MemberListItem.tsx b/packages/features/ee/teams/components/MemberListItem.tsx index 2b356747ca..2f2bacfa32 100644 --- a/packages/features/ee/teams/components/MemberListItem.tsx +++ b/packages/features/ee/teams/components/MemberListItem.tsx @@ -152,7 +152,14 @@ export default function MemberListItem(props: Props) { {props.member.role && }
- + {props.member.email} {bookingLink && ( diff --git a/packages/features/eventtypes/components/ChildrenEventTypeSelect.tsx b/packages/features/eventtypes/components/ChildrenEventTypeSelect.tsx index 328644da30..8a6218d197 100644 --- a/packages/features/eventtypes/components/ChildrenEventTypeSelect.tsx +++ b/packages/features/eventtypes/components/ChildrenEventTypeSelect.tsx @@ -106,6 +106,7 @@ export const ChildrenEventTypeSelect = ({ {children.created && children.owner.username && (
diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index cc5fb76143..1034db6d0d 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -106,4 +106,11 @@ export const APP_CREDENTIAL_SHARING_ENABLED = export const DEFAULT_LIGHT_BRAND_COLOR = "#292929"; export const DEFAULT_DARK_BRAND_COLOR = "#fafafa"; -export const AB_TEST_BUCKET_PROBABILITY = Number(process.env.AB_TEST_BUCKET_PROBABILITY ?? "10"); + +const defaultOnNaN = (testedValue: number, defaultValue: number) => + !Number.isNaN(testedValue) ? testedValue : defaultValue; + +export const AB_TEST_BUCKET_PROBABILITY = defaultOnNaN( + parseInt(process.env.AB_TEST_BUCKET_PROBABILITY ?? "10", 10), + 10 +); diff --git a/packages/lib/hooks/useCompatSearchParams.tsx b/packages/lib/hooks/useCompatSearchParams.tsx index 032ba115a1..3112bb3827 100644 --- a/packages/lib/hooks/useCompatSearchParams.tsx +++ b/packages/lib/hooks/useCompatSearchParams.tsx @@ -8,7 +8,9 @@ export const useCompatSearchParams = () => { Object.getOwnPropertyNames(params).forEach((key) => { searchParams.delete(key); - const param = params[key]; + // Though useParams is supposed to return a string/string[] as the key's value but it is found to return undefined as well. + // Maybe it happens for pages dir when using optional catch-all routes. + const param = params[key] || ""; const paramArr = typeof param === "string" ? param.split("/") : param; paramArr.forEach((p) => { diff --git a/packages/lib/server/getFixedT.ts b/packages/lib/server/getFixedT.ts new file mode 100644 index 0000000000..692c1c8dcc --- /dev/null +++ b/packages/lib/server/getFixedT.ts @@ -0,0 +1,23 @@ +import i18next from "i18next"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; + +//@ts-expect-error no type definitions +import config from "@calcom/web/next-i18next.config"; + +export const create = async (locale: string, ns: string) => { + const { _nextI18Next } = await serverSideTranslations(locale, [ns], config); + + const _i18n = i18next.createInstance(); + _i18n.init({ + lng: locale, + resources: _nextI18Next?.initialI18nStore, + fallbackLng: _nextI18Next?.userConfig?.i18n.defaultLocale, + }); + return _i18n; +}; + +export const getFixedT = async (locale: string, ns: string) => { + const i18n = await create(locale, ns); + + return i18n.getFixedT(locale, ns); +}; diff --git a/packages/prisma/seed.ts b/packages/prisma/seed.ts index 29d981a3a7..78c6861372 100644 --- a/packages/prisma/seed.ts +++ b/packages/prisma/seed.ts @@ -455,6 +455,22 @@ async function main() { }, }); + await createUserAndEventType({ + user: { + email: process.env.E2E_TEST_CALCOM_QA_EMAIL || "qa@example.com", + password: process.env.E2E_TEST_CALCOM_QA_PASSWORD || "qa", + username: "qa", + name: "QA Example", + }, + eventTypes: [ + { + title: "15min", + slug: "15min", + length: 15, + }, + ], + }); + await createTeamAndAddUsers( { name: "Seeded Team", diff --git a/packages/trpc/server/routers/loggedInViewer/deleteCredential.handler.ts b/packages/trpc/server/routers/loggedInViewer/deleteCredential.handler.ts index fb716afb84..e396d5e0d4 100644 --- a/packages/trpc/server/routers/loggedInViewer/deleteCredential.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/deleteCredential.handler.ts @@ -1,4 +1,3 @@ -import { Prisma } from "@prisma/client"; import z from "zod"; import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; @@ -328,52 +327,37 @@ export const deleteCredentialHandler = async ({ ctx, input }: DeleteCredentialOp } } + // Backwards compatibility. Selected calendars cascade on delete when deleting a credential + // If it's a calendar remove it from the SelectedCalendars + if (credential.app?.categories.includes(AppCategories.calendar)) { + try { + const calendar = await getCalendar(credential); + + const calendars = await calendar?.listCalendars(); + + const calendarIds = calendars?.map((cal) => cal.externalId); + + await prisma.selectedCalendar.deleteMany({ + where: { + userId: user.id, + integration: credential.type as string, + externalId: { + in: calendarIds, + }, + }, + }); + } catch (error) { + console.warn( + `Error deleting selected calendars for userId: ${user.id} integration: ${credential.type}`, + error + ); + } + } + // Validated that credential is user's above await prisma.credential.delete({ where: { id: id, }, }); - - // Backwards compatibility. Selected calendars cascade on delete when deleting a credential - // If it's a calendar remove it from the SelectedCalendars - if (credential.app?.categories.includes(AppCategories.calendar)) { - const selectedCalendars = await prisma.selectedCalendar.findMany({ - where: { - userId: user.id, - integration: credential.type as string, - }, - }); - - if (selectedCalendars.length) { - const calendar = await getCalendar(credential); - - const calendars = await calendar?.listCalendars(); - - if (calendars && calendars.length > 0) { - calendars.map(async (cal) => { - prisma.selectedCalendar - .delete({ - where: { - userId_integration_externalId: { - userId: user.id, - externalId: cal.externalId, - integration: cal.integration as string, - }, - }, - }) - .catch((error) => { - if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") { - console.log( - `Error deleting selected calendars for user ${user.id} and calendar ${credential.appId}. Could not find selected calendar.` - ); - } - console.log( - `Error deleting selected calendars for user ${user.id} and calendar ${credential.appId} with error: ${error}` - ); - }); - }); - } - } - } }; diff --git a/packages/trpc/server/routers/viewer/organizations/create.handler.ts b/packages/trpc/server/routers/viewer/organizations/create.handler.ts index 1e0f9a2e03..1b5509afb5 100644 --- a/packages/trpc/server/routers/viewer/organizations/create.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/create.handler.ts @@ -7,12 +7,7 @@ import { sendAdminOrganizationNotification } from "@calcom/emails"; import { hashPassword } from "@calcom/features/auth/lib/hashPassword"; import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains"; import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability"; -import { - IS_TEAM_BILLING_ENABLED, - RESERVED_SUBDOMAINS, - IS_PRODUCTION, - WEBAPP_URL, -} from "@calcom/lib/constants"; +import { IS_TEAM_BILLING_ENABLED, RESERVED_SUBDOMAINS, WEBAPP_URL } from "@calcom/lib/constants"; import { getTranslation } from "@calcom/lib/server/i18n"; import slugify from "@calcom/lib/slugify"; import { prisma } from "@calcom/prisma"; @@ -175,7 +170,6 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => { return { user: { ...createOwnerOrg, password } }; } else { - if (!IS_PRODUCTION) return { checked: true }; const language = await getTranslation(input.language ?? "en", "common"); const secret = createHash("md5") diff --git a/packages/trpc/server/routers/viewer/organizations/verifyCode.handler.ts b/packages/trpc/server/routers/viewer/organizations/verifyCode.handler.ts index 885bb3b6ac..17bfa84be5 100644 --- a/packages/trpc/server/routers/viewer/organizations/verifyCode.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/verifyCode.handler.ts @@ -2,6 +2,7 @@ import { createHash } from "crypto"; import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; import { IS_PRODUCTION } from "@calcom/lib/constants"; +import logger from "@calcom/lib/logger"; import { totpRawCheck } from "@calcom/lib/totp"; import type { ZVerifyCodeInputSchema } from "@calcom/prisma/zod-utils"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; @@ -21,7 +22,10 @@ export const verifyCodeHandler = async ({ ctx, input }: VerifyCodeOptions) => { if (!user || !email || !code) throw new TRPCError({ code: "BAD_REQUEST" }); - if (!IS_PRODUCTION) return true; + if (!IS_PRODUCTION || process.env.NEXT_PUBLIC_IS_E2E) { + logger.warn(`Skipping code verification in dev/E2E environment`); + return true; + } await checkRateLimitAndThrowError({ rateLimitingType: "core", identifier: email, diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts index 79d560050a..5eaebe0844 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts @@ -119,6 +119,11 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) = identifier: usernameOrEmail, token, expires: new Date(new Date().setHours(168)), // +1 week + team: { + connect: { + id: team.id, + }, + }, }, }); diff --git a/packages/trpc/server/routers/viewer/webhook/testTrigger.handler.ts b/packages/trpc/server/routers/viewer/webhook/testTrigger.handler.ts index 7ee9f4bbb9..b4a0c0f46f 100644 --- a/packages/trpc/server/routers/viewer/webhook/testTrigger.handler.ts +++ b/packages/trpc/server/routers/viewer/webhook/testTrigger.handler.ts @@ -10,7 +10,7 @@ type TestTriggerOptions = { }; export const testTriggerHandler = async ({ ctx: _ctx, input }: TestTriggerOptions) => { - const { url, type, payloadTemplate = null } = input; + const { url, type, payloadTemplate = null, secret = null } = input; const translation = await getTranslation("en", "common"); const language = { locale: "en", @@ -40,8 +40,8 @@ export const testTriggerHandler = async ({ ctx: _ctx, input }: TestTriggerOption }; try { - const webhook = { subscriberUrl: url, payloadTemplate, appId: null, secret: null }; - return await sendPayload(null, type, new Date().toISOString(), webhook, data); + const webhook = { subscriberUrl: url, appId: null, payloadTemplate }; + return await sendPayload(secret, type, new Date().toISOString(), webhook, data); } catch (_err) { const error = getErrorFromUnknown(_err); return { diff --git a/packages/trpc/server/routers/viewer/webhook/testTrigger.schema.ts b/packages/trpc/server/routers/viewer/webhook/testTrigger.schema.ts index 53f92f7e88..faeef8ed25 100644 --- a/packages/trpc/server/routers/viewer/webhook/testTrigger.schema.ts +++ b/packages/trpc/server/routers/viewer/webhook/testTrigger.schema.ts @@ -4,6 +4,7 @@ import { webhookIdAndEventTypeIdSchema } from "./types"; export const ZTestTriggerInputSchema = webhookIdAndEventTypeIdSchema.extend({ url: z.string().url(), + secret: z.string().optional(), type: z.string(), payloadTemplate: z.string().optional().nullable(), }); diff --git a/packages/types/Calendar.d.ts b/packages/types/Calendar.d.ts index b8e3989f71..833855250c 100644 --- a/packages/types/Calendar.d.ts +++ b/packages/types/Calendar.d.ts @@ -160,6 +160,7 @@ export interface CalendarEvent { team?: { name: string; members: TeamMember[]; + id: number; }; location?: string | null; conferenceCredentialId?: number; diff --git a/packages/ui/components/form/inputs/HintOrErrors.tsx b/packages/ui/components/form/inputs/HintOrErrors.tsx index a2115f7c56..adc3ce6fca 100644 --- a/packages/ui/components/form/inputs/HintOrErrors.tsx +++ b/packages/ui/components/form/inputs/HintOrErrors.tsx @@ -50,7 +50,10 @@ export function HintsOrErrors({ return (
  • + data-testid="hint-error" + className={ + error !== undefined ? (submitted ? "bg-yellow-200 text-red-700" : "") : "text-green-600" + }> {error !== undefined ? ( submitted ? ( @@ -72,7 +75,9 @@ export function HintsOrErrors({ // errors exist, not custom ones, just show them as is if (fieldErrors) { return ( -
    +
    diff --git a/packages/ui/components/form/select/Select.tsx b/packages/ui/components/form/select/Select.tsx index c6c5b80213..c21772bf7d 100644 --- a/packages/ui/components/form/select/Select.tsx +++ b/packages/ui/components/form/select/Select.tsx @@ -13,7 +13,7 @@ export type SelectProps< Option, IsMulti extends boolean = false, Group extends GroupBase