diff --git a/apps/web/components/apps/CalendarListContainer.tsx b/apps/web/components/apps/CalendarListContainer.tsx index 61082a39fa..379386c57d 100644 --- a/apps/web/components/apps/CalendarListContainer.tsx +++ b/apps/web/components/apps/CalendarListContainer.tsx @@ -1,5 +1,5 @@ import Link from "next/link"; -import { Fragment } from "react"; +import { Fragment, useEffect } from "react"; import { InstallAppButton } from "@calcom/app-store/components"; import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration"; @@ -15,10 +15,12 @@ import { AppSkeletonLoader as SkeletonLoader, ShellSubHeading, Label, + showToast, } from "@calcom/ui"; import { Calendar } from "@calcom/ui/components/icon"; import { QueryCell } from "@lib/QueryCell"; +import useRouterQuery from "@lib/hooks/useRouterQuery"; import AppListCard from "@components/AppListCard"; import AdditionalCalendarSelector from "@components/apps/AdditionalCalendarSelector"; @@ -182,6 +184,15 @@ function ConnectedCalendarsList(props: Props) { export function CalendarListContainer(props: { heading?: boolean; fromOnboarding?: boolean }) { const { t } = useLocale(); const { heading = true, fromOnboarding } = props; + const { error, setQuery: setError } = useRouterQuery("error"); + + useEffect(() => { + if (error === "account_already_linked") { + showToast(t(error), "error", { id: error }); + setError(undefined); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const utils = trpc.useContext(); const onChanged = () => Promise.allSettled([ diff --git a/apps/web/lib/hooks/useRouterQuery.ts b/apps/web/lib/hooks/useRouterQuery.ts index 1ec5a909b2..afa125fbd2 100644 --- a/apps/web/lib/hooks/useRouterQuery.ts +++ b/apps/web/lib/hooks/useRouterQuery.ts @@ -11,7 +11,12 @@ export default function useRouterQuery(name: T) { const setQuery = useCallback( (newValue: string | number | null | undefined) => { const _searchParams = new URLSearchParams(searchParams ?? undefined); - _searchParams.set(name, newValue as string); + if (typeof newValue === "undefined") { + // when newValue is of type undefined, clear the search param. + _searchParams.delete(name); + } else { + _searchParams.set(name, newValue as string); + } router.replace(`${pathname}?${_searchParams.toString()}`); }, [name, pathname, router, searchParams] diff --git a/apps/web/pages/apps/[slug]/index.tsx b/apps/web/pages/apps/[slug]/index.tsx index 8c3cbe4125..8eee52c3f5 100644 --- a/apps/web/pages/apps/[slug]/index.tsx +++ b/apps/web/pages/apps/[slug]/index.tsx @@ -6,9 +6,12 @@ import type { GetStaticPaths } from "next"; import Link from "next/link"; import { IS_PRODUCTION } from "@calcom/lib/constants"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; import prisma from "@calcom/prisma"; +import { showToast } from "@calcom/ui"; import { getStaticProps } from "@lib/apps/[slug]/getStaticProps"; +import useRouterQuery from "@lib/hooks/useRouterQuery"; import type { inferSSRProps } from "@lib/types/inferSSRProps"; import PageWrapper from "@components/PageWrapper"; @@ -17,6 +20,12 @@ import App from "@components/apps/App"; const md = new MarkdownIt("default", { html: true, breaks: true }); function SingleAppPage(props: inferSSRProps) { + const { error, setQuery: setError } = useRouterQuery("error"); + const { t } = useLocale(); + if (error === "account_already_linked") { + showToast(t(error), "error", { id: error }); + setError(undefined); + } // If it's not production environment, it would be a better idea to inform that the App is disabled. if (props.isAppDisabled) { if (!IS_PRODUCTION) { diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index c07d8e82ed..164a162a2a 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2250,5 +2250,6 @@ "having_trouble_finding_time": "Having trouble finding a time?", "show_more": "Show more", "send_booker_to": "Send Booker to", + "account_already_linked": "Account is already linked", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/app-store/googlecalendar/api/callback.ts b/packages/app-store/googlecalendar/api/callback.ts index 3503d6cce7..87fbb2ee2b 100644 --- a/packages/app-store/googlecalendar/api/callback.ts +++ b/packages/app-store/googlecalendar/api/callback.ts @@ -6,6 +6,7 @@ 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 { Prisma } from "@calcom/prisma/client"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getInstalledAppPath from "../../_utils/getInstalledAppPath"; @@ -71,15 +72,6 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) { } } - const credential = await prisma.credential.create({ - data: { - type: "google_calendar", - key, - userId: req.session.user.id, - appId: "google-calendar", - }, - }); - // Set the primary calendar as the first selected calendar // We can ignore this type error because we just validated the key when we init oAuth2Client @@ -93,10 +85,23 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) { }); const cals = await calendar.calendarList.list({ fields: "items(id,summary,primary,accessRole)" }); - const primaryCal = cals.data.items?.find((cal) => cal.primary); + // Primary calendar won't be null, this check satisfies typescript. + if (!primaryCal?.id) { + throw new HttpError({ message: "Internal Error", statusCode: 500 }); + } - if (primaryCal?.id) { + const credential = await prisma.credential.create({ + data: { + type: "google_calendar", + key, + userId: req.session.user.id, + appId: "google-calendar", + }, + }); + // Wrapping in a try/catch to reduce chance of race conditions- + // also this improves performance for most of the happy-paths. + try { await prisma.selectedCalendar.create({ data: { userId: req.session.user.id, @@ -105,6 +110,19 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) { integration: "google_calendar", }, }); + } catch (error) { + await prisma.credential.delete({ where: { id: credential.id } }); + let errorMessage = "something_went_wrong"; + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + errorMessage = "account_already_linked"; + } + res.redirect( + `${ + getSafeRedirectUrl(state?.onErrorReturnTo) ?? + getInstalledAppPath({ variant: "calendar", slug: "google-calendar" }) + }?error=${errorMessage}` + ); + return; } } diff --git a/packages/app-store/office365calendar/api/callback.ts b/packages/app-store/office365calendar/api/callback.ts index afe1bf83f9..05d283af70 100644 --- a/packages/app-store/office365calendar/api/callback.ts +++ b/packages/app-store/office365calendar/api/callback.ts @@ -5,6 +5,7 @@ import { WEBAPP_URL } from "@calcom/lib/constants"; import { handleErrorsJson } from "@calcom/lib/errors"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import prisma from "@calcom/prisma"; +import { Prisma } from "@calcom/prisma/client"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getInstalledAppPath from "../../_utils/getInstalledAppPath"; @@ -76,15 +77,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) responseBody.expiry_date = Math.round(+new Date() / 1000 + responseBody.expires_in); // set expiry date in seconds delete responseBody.expires_in; - const credential = await prisma.credential.create({ - data: { - type: "office365_calendar", - key: responseBody, - userId: req.session?.user.id, - appId: "office365-calendar", - }, - }); - // Set the isDefaultCalendar as selectedCalendar // If a user has multiple calendars, keep on making calls until we find the default calendar let defaultCalendar: OfficeCalendar | undefined = undefined; @@ -119,14 +111,39 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } if (defaultCalendar?.id && req.session?.user?.id) { - await prisma.selectedCalendar.create({ + const credential = await prisma.credential.create({ data: { + type: "office365_calendar", + key: responseBody, userId: req.session?.user.id, - integration: "office365_calendar", - externalId: defaultCalendar.id, - credentialId: credential.id, + appId: "office365-calendar", }, }); + // Wrapping in a try/catch to reduce chance of race conditions- + // also this improves performance for most of the happy-paths. + try { + await prisma.selectedCalendar.create({ + data: { + userId: req.session?.user.id, + integration: "office365_calendar", + externalId: defaultCalendar.id, + credentialId: credential.id, + }, + }); + } catch (error) { + await prisma.credential.delete({ where: { id: credential.id } }); + let errorMessage = "something_went_wrong"; + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + errorMessage = "account_already_linked"; + } + res.redirect( + `${ + getSafeRedirectUrl(state?.onErrorReturnTo) ?? + getInstalledAppPath({ variant: "calendar", slug: "office365-calendar" }) + }?error=${errorMessage}` + ); + return; + } } return res.redirect( diff --git a/packages/app-store/zohocalendar/api/callback.ts b/packages/app-store/zohocalendar/api/callback.ts index f785c27d5a..fcc01265fc 100644 --- a/packages/app-store/zohocalendar/api/callback.ts +++ b/packages/app-store/zohocalendar/api/callback.ts @@ -6,6 +6,7 @@ import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import logger from "@calcom/lib/logger"; import { defaultHandler, defaultResponder } from "@calcom/lib/server"; import prisma from "@calcom/prisma"; +import { Prisma } from "@calcom/prisma/client"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getInstalledAppPath from "../../_utils/getInstalledAppPath"; @@ -64,15 +65,6 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) { expires_in: Math.round(+new Date() / 1000 + responseBody.expires_in), }; - const credential = await prisma.credential.create({ - data: { - type: config.type, - key, - userId: req.session.user.id, - appId: config.slug, - }, - }); - const calendarResponse = await fetch("https://calendar.zoho.com/api/v1/calendars", { method: "GET", headers: { @@ -82,17 +74,43 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) { }); const data = await calendarResponse.json(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any const primaryCalendar = data.calendars.find((calendar: any) => calendar.isdefault); if (primaryCalendar.uid) { - await prisma.selectedCalendar.create({ + const credential = await prisma.credential.create({ data: { + type: config.type, + key, userId: req.session.user.id, - integration: config.type, - externalId: primaryCalendar.uid, - credentialId: credential.id, + appId: config.slug, }, }); + // Wrapping in a try/catch to reduce chance of race conditions- + // also this improves performance for most of the happy-paths. + try { + await prisma.selectedCalendar.create({ + data: { + userId: req.session.user.id, + integration: config.type, + externalId: primaryCalendar.uid, + credentialId: credential.id, + }, + }); + } catch (error) { + await prisma.credential.delete({ where: { id: credential.id } }); + let errorMessage = "something_went_wrong"; + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + errorMessage = "account_already_linked"; + } + res.redirect( + `${ + getSafeRedirectUrl(state?.onErrorReturnTo) ?? + getInstalledAppPath({ variant: config.variant, slug: config.slug }) + }?error=${errorMessage}` + ); + return; + } } res.redirect( diff --git a/packages/ui/components/toast/showToast.tsx b/packages/ui/components/toast/showToast.tsx index 6d0cb41bca..c3cab8dc83 100644 --- a/packages/ui/components/toast/showToast.tsx +++ b/packages/ui/components/toast/showToast.tsx @@ -1,4 +1,5 @@ import classNames from "classnames"; +import type { ToastOptions, Toast } from "react-hot-toast"; import toast from "react-hot-toast"; import { Check, Info } from "../icon"; @@ -76,34 +77,33 @@ export const DefaultToast = ({ message, toastVisible, onClose, toastId }: IToast const TOAST_VISIBLE_DURATION = 6000; +type ToastVariants = "success" | "warning" | "error"; + export function showToast( message: string, - variant: "success" | "warning" | "error", - duration = TOAST_VISIBLE_DURATION + variant: ToastVariants, + // Options or duration (duration for backwards compatibility reasons) + options: number | ToastOptions = TOAST_VISIBLE_DURATION ) { + // + const _options: ToastOptions = typeof options === "number" ? { duration: options } : options; + if (!_options.duration) _options.duration = TOAST_VISIBLE_DURATION; + const onClose = (toastId: string) => { toast.remove(toastId); }; - switch (variant) { - case "success": - return toast.custom( - (t) => , - { duration } - ); - case "error": - return toast.custom( - (t) => , - { duration } - ); - case "warning": - return toast.custom( - (t) => , - { duration } - ); - default: - return toast.custom( - (t) => , - { duration } - ); - } + const toastElements: { [x in ToastVariants]: (t: Toast) => JSX.Element } = { + success: (t) => ( + + ), + error: (t) => , + warning: (t) => ( + + ), + }; + return toast.custom( + toastElements[variant] || + ((t) => ), + _options + ); }