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:
co-authored by
Keith Williams
Alex van Andel
Udit Takkar
parent
97dcb2a2a9
commit
3348e7be2f
@@ -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([
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user