fix: prevent duplicate calendar account linking (#13310)

* fix: prevent signing up multiple times from same account

* revert: lark calendar changes

* credential clean up if duplicate

* fix code duplication and check before credential creation

* feat: useRouterQuery allow unset queryParam

* feat: showToast on account duplication attempt

* Small tweak to copy

* Updated other calendars not to use checkDuplicateCalendar

* Add account already linked to apps/installed/calendar

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
This commit is contained in:
Amit Sharma
2024-02-08 10:52:32 +00:00
committed by GitHub
co-authored by Keith Williams Alex van Andel Udit Takkar
parent 97dcb2a2a9
commit 3348e7be2f
8 changed files with 142 additions and 63 deletions
@@ -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([
+6 -1
View File
@@ -11,7 +11,12 @@ export default function useRouterQuery<T extends string>(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]
+9
View File
@@ -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<typeof getStaticProps>) {
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) {
@@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -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;
}
}
@@ -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(
+31 -13
View File
@@ -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(
+24 -24
View File
@@ -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) => <SuccessToast message={message} toastVisible={t.visible} onClose={onClose} toastId={t.id} />,
{ duration }
);
case "error":
return toast.custom(
(t) => <ErrorToast message={message} toastVisible={t.visible} onClose={onClose} toastId={t.id} />,
{ duration }
);
case "warning":
return toast.custom(
(t) => <WarningToast message={message} toastVisible={t.visible} onClose={onClose} toastId={t.id} />,
{ duration }
);
default:
return toast.custom(
(t) => <DefaultToast message={message} toastVisible={t.visible} onClose={onClose} toastId={t.id} />,
{ duration }
);
}
const toastElements: { [x in ToastVariants]: (t: Toast) => JSX.Element } = {
success: (t) => (
<SuccessToast message={message} toastVisible={t.visible} onClose={onClose} toastId={t.id} />
),
error: (t) => <ErrorToast message={message} toastVisible={t.visible} onClose={onClose} toastId={t.id} />,
warning: (t) => (
<WarningToast message={message} toastVisible={t.visible} onClose={onClose} toastId={t.id} />
),
};
return toast.custom(
toastElements[variant] ||
((t) => <DefaultToast message={message} toastVisible={t.visible} onClose={onClose} toastId={t.id} />),
_options
);
}