From c6246c9408619ccd17900afb0d490113fbf114a4 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Tue, 29 Aug 2023 02:02:20 -0400 Subject: [PATCH 01/13] refactor: Eventtype app context after orgs (#10781) Co-authored-by: Hariom Balhara --- packages/app-store/EventTypeAppContext.tsx | 31 +++++++++++++------ packages/app-store/_components/AppCard.tsx | 11 ++----- .../_components/EventTypeAppCardInterface.tsx | 2 +- packages/app-store/_utils/useIsAppEnabled.ts | 2 +- .../components/EventTypeAppCardInterface.tsx | 3 +- .../components/EventTypeAppCardInterface.tsx | 5 +-- .../components/EventTypeAppCardInterface.tsx | 5 +-- .../components/EventTypeAppCardInterface.tsx | 5 +-- .../components/EventTypeAppCardInterface.tsx | 5 +-- .../components/EventTypeAppCardInterface.tsx | 5 +-- .../components/EventTypeAppCardInterface.tsx | 3 +- .../components/EventTypeAppCardInterface.tsx | 5 +-- .../components/EventTypeAppCardInterface.tsx | 5 +-- .../components/EventTypeAppCardInterface.tsx | 5 +-- .../components/EventTypeAppCardInterface.tsx | 3 +- .../components/EventTypeAppCardInterface.tsx | 5 +-- 16 files changed, 38 insertions(+), 62 deletions(-) diff --git a/packages/app-store/EventTypeAppContext.tsx b/packages/app-store/EventTypeAppContext.tsx index 0ddaf7a821..584d6a2fa0 100644 --- a/packages/app-store/EventTypeAppContext.tsx +++ b/packages/app-store/EventTypeAppContext.tsx @@ -5,15 +5,21 @@ export type GetAppData = (key: string) => unknown; export type SetAppData = (key: string, value: unknown) => void; type LockedIcon = JSX.Element | false | undefined; type Disabled = boolean | undefined; -// eslint-disable-next-line @typescript-eslint/no-empty-function -const EventTypeAppContext = React.createContext<[GetAppData, SetAppData, LockedIcon, Disabled]>([ - () => ({}), - () => ({}), - undefined, - undefined, -]); -export type SetAppDataGeneric = < +type AppContext = { + getAppData: GetAppData; + setAppData: SetAppData; + LockedIcon?: LockedIcon; + disabled?: Disabled; +}; + +// eslint-disable-next-line @typescript-eslint/no-empty-function +const EventTypeAppContext = React.createContext({ + getAppData: () => ({}), + setAppData: () => ({}), +}); + +type SetAppDataGeneric = < TKey extends keyof z.infer, TValue extends z.infer[TKey] >( @@ -21,7 +27,7 @@ export type SetAppDataGeneric = < value: TValue ) => void; -export type GetAppDataGeneric = >( +type GetAppDataGeneric = >( key: TKey ) => z.infer[TKey]; @@ -29,7 +35,12 @@ export const useAppContextWithSchema = () => { type GetAppData = GetAppDataGeneric; type SetAppData = SetAppDataGeneric; // TODO: Not able to do it without type assertion here - const context = React.useContext(EventTypeAppContext) as [GetAppData, SetAppData, LockedIcon, Disabled]; + const context = React.useContext(EventTypeAppContext) as { + getAppData: GetAppData; + setAppData: SetAppData; + LockedIcon: LockedIcon; + disabled: Disabled; + }; return context; }; export default EventTypeAppContext; diff --git a/packages/app-store/_components/AppCard.tsx b/packages/app-store/_components/AppCard.tsx index d73ba73c37..5489c1ea95 100644 --- a/packages/app-store/_components/AppCard.tsx +++ b/packages/app-store/_components/AppCard.tsx @@ -1,12 +1,11 @@ import { useAutoAnimate } from "@formkit/auto-animate/react"; import Link from "next/link"; +import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext"; import { classNames } from "@calcom/lib"; import type { RouterOutputs } from "@calcom/trpc/react"; import { Switch, Badge, Avatar } from "@calcom/ui"; -import type { SetAppDataGeneric } from "../EventTypeAppContext"; -import type { eventTypeAppCardZod } from "../eventTypeAppCardZod"; import type { CredentialOwner } from "../types"; import OmniInstallAppButton from "./OmniInstallAppButton"; @@ -16,24 +15,20 @@ export default function AppCard({ switchOnClick, switchChecked, children, - setAppData, returnTo, teamId, - disableSwitch, - LockedIcon, }: { app: RouterOutputs["viewer"]["integrations"]["items"][number] & { credentialOwner?: CredentialOwner }; description?: React.ReactNode; switchChecked?: boolean; switchOnClick?: (e: boolean) => void; children?: React.ReactNode; - setAppData: SetAppDataGeneric; returnTo?: string; teamId?: number; - disableSwitch?: boolean; LockedIcon?: React.ReactNode; }) { const [animationRef] = useAutoAnimate(); + const { setAppData, LockedIcon, disabled } = useAppContextWithSchema(); return (
{ if (switchOnClick) { switchOnClick(enabled); diff --git a/packages/app-store/_components/EventTypeAppCardInterface.tsx b/packages/app-store/_components/EventTypeAppCardInterface.tsx index e312297007..8f34ce9e11 100644 --- a/packages/app-store/_components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/_components/EventTypeAppCardInterface.tsx @@ -19,7 +19,7 @@ export const EventTypeAppCard = (props: { const { app, getAppData, setAppData, LockedIcon, disabled } = props; return ( - + { if (!app.credentialOwner) { return getAppData("enabled"); diff --git a/packages/app-store/basecamp3/components/EventTypeAppCardInterface.tsx b/packages/app-store/basecamp3/components/EventTypeAppCardInterface.tsx index d742941000..cc81f0237e 100644 --- a/packages/app-store/basecamp3/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/basecamp3/components/EventTypeAppCardInterface.tsx @@ -9,7 +9,7 @@ import { Select } from "@calcom/ui"; import type { appDataSchema } from "../zod"; const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app }) { - const [getAppData, setAppData] = useAppContextWithSchema(); + const { getAppData } = useAppContextWithSchema(); const [enabled, setEnabled] = useState(getAppData("enabled")); const [projects, setProjects] = useState(); const [selectedProject, setSelectedProject] = useState(); @@ -32,7 +32,6 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ return ( { if (!e) { diff --git a/packages/app-store/fathom/components/EventTypeAppCardInterface.tsx b/packages/app-store/fathom/components/EventTypeAppCardInterface.tsx index 44e3ea5c72..5320587871 100644 --- a/packages/app-store/fathom/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/fathom/components/EventTypeAppCardInterface.tsx @@ -7,16 +7,13 @@ import { TextField } from "@calcom/ui"; import type { appDataSchema } from "../zod"; const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { - const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema(); + const { getAppData, setAppData, disabled } = useAppContextWithSchema(); const trackingId = getAppData("trackingId"); const { enabled, updateEnabled } = useIsAppEnabled(app); return ( { updateEnabled(e); }} diff --git a/packages/app-store/ga4/components/EventTypeAppCardInterface.tsx b/packages/app-store/ga4/components/EventTypeAppCardInterface.tsx index 44e3ea5c72..5320587871 100644 --- a/packages/app-store/ga4/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/ga4/components/EventTypeAppCardInterface.tsx @@ -7,16 +7,13 @@ import { TextField } from "@calcom/ui"; import type { appDataSchema } from "../zod"; const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { - const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema(); + const { getAppData, setAppData, disabled } = useAppContextWithSchema(); const trackingId = getAppData("trackingId"); const { enabled, updateEnabled } = useIsAppEnabled(app); return ( { updateEnabled(e); }} diff --git a/packages/app-store/giphy/components/EventTypeAppCardInterface.tsx b/packages/app-store/giphy/components/EventTypeAppCardInterface.tsx index 970b7bc099..b285eeedef 100644 --- a/packages/app-store/giphy/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/giphy/components/EventTypeAppCardInterface.tsx @@ -8,7 +8,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { appDataSchema } from "../zod"; const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { - const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema(); + const { getAppData, setAppData, disabled } = useAppContextWithSchema(); const thankYouPage = getAppData("thankYouPage"); const { enabled: showGifSelection, updateEnabled: setShowGifSelection } = useIsAppEnabled(app); @@ -16,11 +16,8 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ return ( { setShowGifSelection(e); }} diff --git a/packages/app-store/gtm/components/EventTypeAppCardInterface.tsx b/packages/app-store/gtm/components/EventTypeAppCardInterface.tsx index 44e3ea5c72..5320587871 100644 --- a/packages/app-store/gtm/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/gtm/components/EventTypeAppCardInterface.tsx @@ -7,16 +7,13 @@ import { TextField } from "@calcom/ui"; import type { appDataSchema } from "../zod"; const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { - const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema(); + const { getAppData, setAppData, disabled } = useAppContextWithSchema(); const trackingId = getAppData("trackingId"); const { enabled, updateEnabled } = useIsAppEnabled(app); return ( { updateEnabled(e); }} diff --git a/packages/app-store/metapixel/components/EventTypeAppCardInterface.tsx b/packages/app-store/metapixel/components/EventTypeAppCardInterface.tsx index d8d3940c84..668523d3ad 100644 --- a/packages/app-store/metapixel/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/metapixel/components/EventTypeAppCardInterface.tsx @@ -7,16 +7,13 @@ import { TextField } from "@calcom/ui"; import type { appDataSchema } from "../zod"; const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { - const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema(); + const { getAppData, setAppData, disabled } = useAppContextWithSchema(); const trackingId = getAppData("trackingId"); const { enabled, updateEnabled } = useIsAppEnabled(app); return ( diff --git a/packages/app-store/paypal/components/EventTypeAppCardInterface.tsx b/packages/app-store/paypal/components/EventTypeAppCardInterface.tsx index 08a6eff4ce..c9b92f441b 100644 --- a/packages/app-store/paypal/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/paypal/components/EventTypeAppCardInterface.tsx @@ -16,7 +16,7 @@ type Option = { value: string; label: string }; const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { const { asPath } = useRouter(); - const [getAppData, setAppData] = useAppContextWithSchema(); + const { getAppData, setAppData } = useAppContextWithSchema(); const price = getAppData("price"); const currency = getAppData("currency"); const [selectedCurrency, setSelectedCurrency] = useState( @@ -38,7 +38,6 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ return ( { diff --git a/packages/app-store/plausible/components/EventTypeAppCardInterface.tsx b/packages/app-store/plausible/components/EventTypeAppCardInterface.tsx index 63abae7450..2b24f7c4fb 100644 --- a/packages/app-store/plausible/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/plausible/components/EventTypeAppCardInterface.tsx @@ -7,17 +7,14 @@ import { TextField } from "@calcom/ui"; import type { appDataSchema } from "../zod"; const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { - const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema(); + const { getAppData, setAppData, disabled } = useAppContextWithSchema(); const plausibleUrl = getAppData("PLAUSIBLE_URL"); const trackingId = getAppData("trackingId"); const { enabled, updateEnabled } = useIsAppEnabled(app); return ( { updateEnabled(e); }} diff --git a/packages/app-store/qr_code/components/EventTypeAppCardInterface.tsx b/packages/app-store/qr_code/components/EventTypeAppCardInterface.tsx index fc492f20c1..ceb1d451fc 100644 --- a/packages/app-store/qr_code/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/qr_code/components/EventTypeAppCardInterface.tsx @@ -11,7 +11,7 @@ import type { appDataSchema } from "../zod"; const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ eventType, app }) { const { t } = useLocale(); - const [_getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema(); + const { disabled } = useAppContextWithSchema(); const [additionalParameters, setAdditionalParameters] = useState(""); const { enabled, updateEnabled } = useIsAppEnabled(app); @@ -37,10 +37,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ return ( { updateEnabled(e); }} diff --git a/packages/app-store/stripepayment/components/EventTypeAppCardInterface.tsx b/packages/app-store/stripepayment/components/EventTypeAppCardInterface.tsx index b499a2749a..e49c827a81 100644 --- a/packages/app-store/stripepayment/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/stripepayment/components/EventTypeAppCardInterface.tsx @@ -15,7 +15,7 @@ type Option = { value: string; label: string }; const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { const pathname = usePathname(); - const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema(); + const { getAppData, setAppData, disabled } = useAppContextWithSchema(); const price = getAppData("price"); const currency = getAppData("currency"); const paymentOption = getAppData("paymentOption"); @@ -38,10 +38,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ return ( { setRequirePayment(enabled); diff --git a/packages/app-store/templates/booking-pages-tag/components/EventTypeAppCardInterface.tsx b/packages/app-store/templates/booking-pages-tag/components/EventTypeAppCardInterface.tsx index ae14000501..d8e07f9407 100644 --- a/packages/app-store/templates/booking-pages-tag/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/templates/booking-pages-tag/components/EventTypeAppCardInterface.tsx @@ -8,13 +8,12 @@ import { TextField } from "@calcom/ui"; import type { appDataSchema } from "../zod"; const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { - const [getAppData, setAppData] = useAppContextWithSchema(); + const { getAppData, setAppData } = useAppContextWithSchema(); const trackingId = getAppData("trackingId"); const [enabled, setEnabled] = useState(getAppData("enabled")); return ( { if (!e) { diff --git a/packages/app-store/templates/event-type-app-card/components/EventTypeAppCardInterface.tsx b/packages/app-store/templates/event-type-app-card/components/EventTypeAppCardInterface.tsx index 85f4a6a504..c79e7ecff2 100644 --- a/packages/app-store/templates/event-type-app-card/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/templates/event-type-app-card/components/EventTypeAppCardInterface.tsx @@ -7,16 +7,13 @@ import { Sunrise, Sunset } from "@calcom/ui/components/icon"; import type { appDataSchema } from "../zod"; const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ eventType, app }) { - const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema(); + const { getAppData, setAppData } = useAppContextWithSchema(); const isSunrise = getAppData("isSunrise"); const { enabled, updateEnabled } = useIsAppEnabled(app); return ( { if (!e) { updateEnabled(false); From 8c8550e6f87867b038338611c02a8b02dc605865 Mon Sep 17 00:00:00 2001 From: Mehul Date: Tue, 29 Aug 2023 16:02:56 +0530 Subject: [PATCH 02/13] fix: switching between months leads to incorrect autoselect first available date (#10366) Co-authored-by: Hariom Balhara --- .../bookings/Booker/components/DatePicker.tsx | 9 +-- packages/features/bookings/Booker/store.ts | 4 +- packages/features/calendars/DatePicker.tsx | 65 ++++++++++++++++--- packages/features/embed/Embed.tsx | 6 +- .../components/DateOverrideInputDialog.tsx | 6 +- .../use-schedule/useNonEmptyScheduleDays.ts | 2 +- 6 files changed, 69 insertions(+), 23 deletions(-) diff --git a/packages/features/bookings/Booker/components/DatePicker.tsx b/packages/features/bookings/Booker/components/DatePicker.tsx index 10ec92c909..36bed16909 100644 --- a/packages/features/bookings/Booker/components/DatePicker.tsx +++ b/packages/features/bookings/Booker/components/DatePicker.tsx @@ -24,13 +24,8 @@ export const DatePicker = () => { return ( { - setSelectedDate(date.format("YYYY-MM-DD")); - }} - onMonthChange={(date: Dayjs) => { - setMonth(date.format("YYYY-MM")); - setSelectedDate(date.format("YYYY-MM-DD")); - }} + onChange={(date) => setSelectedDate(date ? date.format("YYYY-MM-DD") : date)} + onMonthChange={(date) => setMonth(date.format("YYYY-MM"))} includedDates={nonEmptyScheduleDays} locale={i18n.language} browsingDate={month ? dayjs(month) : undefined} diff --git a/packages/features/bookings/Booker/store.ts b/packages/features/bookings/Booker/store.ts index 17a2e8d86b..8644e575db 100644 --- a/packages/features/bookings/Booker/store.ts +++ b/packages/features/bookings/Booker/store.ts @@ -160,7 +160,8 @@ export const useBookerStore = create((set, get) => ({ updateQueryParam("date", selectedDate ?? ""); // Setting month make sure small calendar in fullscreen layouts also updates. - if (newSelection.month() !== currentSelection.month()) { + // If selectedDate is null, prevents setting month to Invalid-Date + if (selectedDate && newSelection.month() !== currentSelection.month() ) { set({ month: newSelection.format("YYYY-MM") }); updateQueryParam("month", newSelection.format("YYYY-MM")); } @@ -193,7 +194,6 @@ export const useBookerStore = create((set, get) => ({ setMonth: (month: string | null) => { set({ month, selectedTimeslot: null }); updateQueryParam("month", month ?? ""); - get().setSelectedDate(null); }, isTeamEvent: false, seatedEventData: { diff --git a/packages/features/calendars/DatePicker.tsx b/packages/features/calendars/DatePicker.tsx index bb4485f5ea..e4bfda0d1a 100644 --- a/packages/features/calendars/DatePicker.tsx +++ b/packages/features/calendars/DatePicker.tsx @@ -16,7 +16,7 @@ export type DatePickerProps = { /** which day of the week to render the calendar. Usually Sunday (=0) or Monday (=1) - default: Sunday */ weekStart?: 0 | 1 | 2 | 3 | 4 | 5 | 6; /** Fires whenever a selected date is changed. */ - onChange: (date: Dayjs) => void; + onChange: (date: Dayjs | null) => void; /** Fires when the month is changed. */ onMonthChange?: (date: Dayjs) => void; /** which date or dates are currently selected (not tracked from here) */ @@ -30,7 +30,7 @@ export type DatePickerProps = { /** Defaults to [], which dates are not bookable. Array of valid dates like: ["2022-04-23", "2022-04-24"] */ excludedDates?: string[]; /** defaults to all, which dates are bookable (inverse of excludedDates) */ - includedDates?: string[]; + includedDates?: string[] | null; /** allows adding classes to the container */ className?: string; /** Shows a small loading spinner next to the month name */ @@ -121,7 +121,7 @@ const Days = ({ // Create placeholder elements for empty days in first week const weekdayOfFirst = browsingDate.date(1).day(); const currentDate = minDate.utcOffset(browsingDate.utcOffset()); - const availableDates = (includedDates: string[] | undefined) => { + const availableDates = (includedDates: string[] | undefined | null) => { const dates = []; const lastDateOfMonth = browsingDate.date(daysInMonth(browsingDate)); for ( @@ -148,6 +148,21 @@ const Days = ({ days.push(date); } + const daysToRenderForTheMonth = days.map((day) => { + if (!day) return { day: null, disabled: true }; + return { + day: day, + disabled: + (includedDates && !includedDates.includes(yyyymmdd(day))) || excludedDates.includes(yyyymmdd(day)), + }; + }); + + useHandleInitialDateSelection({ + daysToRenderForTheMonth, + selected, + onChange: props.onChange, + }); + const [selectedDatesAndTimes] = useBookerStore((state) => [state.selectedDatesAndTimes], shallow); const isActive = (day: dayjs.Dayjs) => { @@ -177,7 +192,7 @@ const Days = ({ return ( <> - {days.map((day, idx) => ( + {daysToRenderForTheMonth.map(({ day, disabled }, idx) => (
{day === null ? (
@@ -194,10 +209,7 @@ const Days = ({ onClick={() => { props.onChange(day); }} - disabled={ - (includedDates && !includedDates.includes(yyyymmdd(day))) || - excludedDates.includes(yyyymmdd(day)) - } + disabled={disabled} active={isActive(day)} /> )} @@ -293,4 +305,41 @@ const DatePicker = ({ ); }; +/** + * Takes care of selecting a valid date in the month if the selected date is not available in the month + */ +const useHandleInitialDateSelection = ({ + daysToRenderForTheMonth, + selected, + onChange, +}: { + daysToRenderForTheMonth: { day: Dayjs | null; disabled: boolean }[]; + selected: Dayjs | Dayjs[] | null | undefined; + onChange: (date: Dayjs | null) => void; +}) => { + // Let's not do something for now in case of multiple selected dates as behaviour is unclear and it's not needed at the moment + if (selected instanceof Array) { + return; + } + const firstAvailableDateOfTheMonth = daysToRenderForTheMonth.find((day) => !day.disabled)?.day; + + const isSelectedDateAvailable = selected + ? daysToRenderForTheMonth.some(({ day, disabled }) => { + if (day && yyyymmdd(day) === yyyymmdd(selected) && !disabled) return true; + }) + : false; + + if (firstAvailableDateOfTheMonth) { + // If selected date not available in the month, select the first available date of the month + if (!isSelectedDateAvailable) { + onChange(firstAvailableDateOfTheMonth); + } + } else { + // No date is available and if we were asked to select something inform that it couldn't be selected. This would actually help in not showing the timeslots section(with No Time Available) when no date in the month is available + if (selected) { + onChange(null); + } + } +}; + export default DatePicker; diff --git a/packages/features/embed/Embed.tsx b/packages/features/embed/Embed.tsx index b91106302e..9154d4b56d 100644 --- a/packages/features/embed/Embed.tsx +++ b/packages/features/embed/Embed.tsx @@ -229,10 +229,8 @@ const EmailEmbed = ({ eventType, username }: { eventType?: EventType; username:
{t("select_date")}
{ - setSelectedDate(date.format("YYYY-MM-DD")); - }} - onMonthChange={(date: Dayjs) => { + onChange={(date) => setSelectedDate(date ? date.format("YYYY-MM-DD") : date)} + onMonthChange={(date) => { setMonth(date.format("YYYY-MM")); setSelectedDate(date.format("YYYY-MM-DD")); }} diff --git a/packages/features/schedules/components/DateOverrideInputDialog.tsx b/packages/features/schedules/components/DateOverrideInputDialog.tsx index 9dd0d70e4d..f908d35c99 100644 --- a/packages/features/schedules/components/DateOverrideInputDialog.tsx +++ b/packages/features/schedules/components/DateOverrideInputDialog.tsx @@ -51,7 +51,11 @@ const DateOverrideForm = ({ const [selectedDates, setSelectedDates] = useState(value ? [dayjs.utc(value[0].start)] : []); - const onDateChange = (newDate: Dayjs) => { + const onDateChange = (newDate: Dayjs | null) => { + // If no date is selected, do nothing + if (!newDate) { + return; + } // If clicking on a selected date unselect it if (selectedDates.some((date) => yyyymmdd(date) === yyyymmdd(newDate))) { setSelectedDates(selectedDates.filter((date) => yyyymmdd(date) !== yyyymmdd(newDate))); diff --git a/packages/features/schedules/lib/use-schedule/useNonEmptyScheduleDays.ts b/packages/features/schedules/lib/use-schedule/useNonEmptyScheduleDays.ts index eca607c322..d7945cf311 100644 --- a/packages/features/schedules/lib/use-schedule/useNonEmptyScheduleDays.ts +++ b/packages/features/schedules/lib/use-schedule/useNonEmptyScheduleDays.ts @@ -3,7 +3,7 @@ import { useMemo } from "react"; import type { Slots } from "../use-schedule"; export const getNonEmptyScheduleDays = (slots?: Slots) => { - if (typeof slots === "undefined") return []; + if (typeof slots === "undefined") return null; return Object.keys(slots).filter((day) => slots[day].length > 0); }; From f6f4b67bf9a22e139a72315677ab1e64043f1f02 Mon Sep 17 00:00:00 2001 From: Patel Divyesh Date: Tue, 29 Aug 2023 16:05:50 +0530 Subject: [PATCH 03/13] fix: Team Logo not Showing up in Team Event Create Modal (#10959) Co-authored-by: Peer Richelsen --- .../server/routers/viewer/eventTypes/getByViewer.handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts index f6e2791299..3741aaa080 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts @@ -272,7 +272,7 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => ...group.metadata, teamId: group.teamId, membershipRole: group.membershipRole, - image: `${bookerUrl}${group.teamId ? "/team" : ""}/${group.profile.slug}/avatar.png`, + image: `${bookerUrl}/${group.profile.slug}/avatar.png`, })), }; }; From 1fa87ae179317eb77b1524ee3d631a3295b85450 Mon Sep 17 00:00:00 2001 From: Ravan <69167444+RamK777-stack@users.noreply.github.com> Date: Tue, 29 Aug 2023 16:06:15 +0530 Subject: [PATCH 04/13] fix: Modal popup overflow issue in reschedule booking (#10976) Co-authored-by: Udit Takkar Co-authored-by: Peer Richelsen --- .../components/dialog/EditLocationDialog.tsx | 32 +++++++++---------- .../components/dialog/RescheduleDialog.tsx | 2 +- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/apps/web/components/dialog/EditLocationDialog.tsx b/apps/web/components/dialog/EditLocationDialog.tsx index b962d55971..c1be998af3 100644 --- a/apps/web/components/dialog/EditLocationDialog.tsx +++ b/apps/web/components/dialog/EditLocationDialog.tsx @@ -382,24 +382,22 @@ export const EditLocationDialog = (props: ISetLocationDialog) => { }} /> {selectedLocation && LocationOptions} - -
- + + - -
+
diff --git a/apps/web/components/dialog/RescheduleDialog.tsx b/apps/web/components/dialog/RescheduleDialog.tsx index 6109bb88be..973bf59cbf 100644 --- a/apps/web/components/dialog/RescheduleDialog.tsx +++ b/apps/web/components/dialog/RescheduleDialog.tsx @@ -41,7 +41,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => { return ( - +
From b285f27d00289939b5183e7ce3587b3f12db7c32 Mon Sep 17 00:00:00 2001 From: Monto <138862352+montocoder@users.noreply.github.com> Date: Tue, 29 Aug 2023 13:56:26 +0200 Subject: [PATCH 05/13] feat: option for adding ics events to workflow reminders (#10856) Co-authored-by: SMLukwiya Co-authored-by: Monto <138862352+monto7926@users.noreply.github.com> Co-authored-by: Udit Takkar --- apps/web/public/static/locales/en/common.json | 1 + .../workflows/api/scheduleEmailReminders.ts | 76 +++++++++++++++++ .../components/WorkflowDetailsPage.tsx | 1 + .../components/WorkflowStepContainer.tsx | 23 +++++ .../lib/reminders/emailReminderManager.ts | 84 +++++++++++++++++-- .../lib/reminders/reminderScheduler.ts | 3 +- .../lib/reminders/smsReminderManager.ts | 3 +- .../features/ee/workflows/pages/workflow.tsx | 1 + packages/features/form-builder/utils.ts | 5 +- .../migration.sql | 2 + packages/prisma/schema.prisma | 1 + .../viewer/workflows/update.handler.ts | 1 + .../routers/viewer/workflows/update.schema.ts | 1 + 13 files changed, 192 insertions(+), 10 deletions(-) create mode 100644 packages/prisma/migrations/20230828094603_add_include_calendar_event/migration.sql diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index c7546eab0b..082de5a93c 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2026,5 +2026,6 @@ "value": "Value", "your_organization_updated_sucessfully": "Your organization updated successfully", "seat_options_doesnt_multiple_durations": "Seat option doesn't support multiple durations", + "include_calendar_event": "Include calendar event", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/features/ee/workflows/api/scheduleEmailReminders.ts b/packages/features/ee/workflows/api/scheduleEmailReminders.ts index f6ac593d94..b63b1ca47c 100644 --- a/packages/features/ee/workflows/api/scheduleEmailReminders.ts +++ b/packages/features/ee/workflows/api/scheduleEmailReminders.ts @@ -1,10 +1,16 @@ /* Schedule any workflow reminder that falls within 72 hours for email */ +import type { Prisma } from "@prisma/client"; import client from "@sendgrid/client"; import sgMail from "@sendgrid/mail"; +import { createEvent } from "ics"; +import type { DateArray } from "ics"; import type { NextApiRequest, NextApiResponse } from "next"; +import { RRule } from "rrule"; +import { v4 as uuidv4 } from "uuid"; import dayjs from "@calcom/dayjs"; import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; +import { parseRecurringEvent } from "@calcom/lib"; import { defaultHandler } from "@calcom/lib/server"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; import prisma from "@calcom/prisma"; @@ -20,6 +26,65 @@ const senderEmail = process.env.SENDGRID_EMAIL as string; sgMail.setApiKey(sendgridAPIKey); +type Booking = Prisma.BookingGetPayload<{ + include: { + eventType: true; + user: true; + attendees: true; + }; +}>; + +function getiCalEventAsString(booking: Booking) { + let recurrenceRule: string | undefined = undefined; + const recurringEvent = parseRecurringEvent(booking.eventType?.recurringEvent); + if (recurringEvent?.count) { + recurrenceRule = new RRule(recurringEvent).toString().replace("RRULE:", ""); + } + + const uid = uuidv4(); + + const icsEvent = createEvent({ + uid, + startInputType: "utc", + start: dayjs(booking.startTime.toISOString() || "") + .utc() + .toArray() + .slice(0, 6) + .map((v, i) => (i === 1 ? v + 1 : v)) as DateArray, + duration: { + minutes: dayjs(booking.endTime.toISOString() || "").diff( + dayjs(booking.startTime.toISOString() || ""), + "minute" + ), + }, + title: booking.eventType?.title || "", + description: booking.description || "", + location: booking.location || "", + organizer: { + email: booking.user?.email || "", + name: booking.user?.name || "", + }, + attendees: [ + { + name: booking.attendees[0].name, + email: booking.attendees[0].email, + partstat: "ACCEPTED", + role: "REQ-PARTICIPANT", + rsvp: true, + }, + ], + method: "REQUEST", + ...{ recurrenceRule }, + status: "CONFIRMED", + }); + + if (icsEvent.error) { + throw icsEvent.error; + } + + return icsEvent.value; +} + async function handler(req: NextApiRequest, res: NextApiResponse) { const apiKey = req.headers.authorization || req.query.apiKey; if (process.env.CRON_API_KEY !== apiKey) { @@ -258,6 +323,17 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { enable: sandboxMode, }, }, + attachments: reminder.workflowStep.includeCalendarEvent + ? [ + { + content: Buffer.from(getiCalEventAsString(reminder.booking) || "").toString("base64"), + filename: "event.ics", + type: "text/calendar; method=REQUEST", + disposition: "attachment", + contentId: uuidv4(), + }, + ] + : undefined, }); } diff --git a/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx b/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx index 4760d25175..a5950cc49c 100644 --- a/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx +++ b/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx @@ -113,6 +113,7 @@ export default function WorkflowDetailsPage(props: Props) { sender: isSMSAction(action) ? sender || SENDER_ID : SENDER_ID, senderName: !isSMSAction(action) ? senderName || SENDER_NAME : SENDER_NAME, numberVerificationPending: false, + includeCalendarEvent: false, }; steps?.push(step); form.setValue("steps", steps); diff --git a/packages/features/ee/workflows/components/WorkflowStepContainer.tsx b/packages/features/ee/workflows/components/WorkflowStepContainer.tsx index 228c890c9e..65f9e14ac5 100644 --- a/packages/features/ee/workflows/components/WorkflowStepContainer.tsx +++ b/packages/features/ee/workflows/components/WorkflowStepContainer.tsx @@ -861,6 +861,29 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { {form.formState?.errors?.steps[step.stepNumber - 1]?.reminderBody?.message || ""}

)} + {isEmailSubjectNeeded && ( +
+ ( + + form.setValue( + `steps.${step.stepNumber - 1}.includeCalendarEvent`, + e.target.checked + ) + } + /> + )} + /> +
+ )} {!props.readOnly && (
+ ), + isEnabled: true, + }, + { title: "Step 3", description: "Description 3", content:

Step 3

}, +]; + +const props = { + href: "/test/mock", + steps: steps, + nextLabel: "Next step", + prevLabel: "Previous step", + finishLabel: "Finish", +}; + +let currentStepNavigation: number; + +const renderComponent = (extraProps?: { disableNavigation: boolean }) => + render(); + +describe("Tests for WizardForm component", () => { + test("Should handle all the steps correctly", async () => { + currentStepNavigation = 1; + const { queryByTestId, queryByText, rerender } = renderComponent(); + const { prevLabel, nextLabel, finishLabel } = props; + const stepInfo = { + title: queryByTestId("step-title"), + description: queryByTestId("step-description"), + }; + + await waitFor(() => { + steps.forEach((step, index) => { + rerender(); + + const { title, description } = step; + const buttons = { + prev: queryByText(prevLabel), + next: queryByText(nextLabel), + finish: queryByText(finishLabel), + }; + + expect(stepInfo.title).toHaveTextContent(title); + expect(stepInfo.description).toHaveTextContent(description); + + if (index === 0) { + // case of first step + expect(buttons.prev && buttons.finish).not.toBeInTheDocument(); + expect(buttons.next).toBeInTheDocument(); + } else if (index === steps.length - 1) { + // case of last step + expect(buttons.prev && buttons.finish).toBeInTheDocument(); + expect(buttons.next).not.toBeInTheDocument(); + } else { + // case of in-between steps + expect(buttons.prev && buttons.next).toBeInTheDocument(); + expect(buttons.finish).not.toBeInTheDocument(); + } + + currentStepNavigation++; + }); + }); + }); + + describe("Should handle the visibility of the content", async () => { + test("Should render JSX content correctly", async () => { + currentStepNavigation = 1; + const { getByTestId, getByText } = renderComponent(); + const currentStep = steps[0]; + + expect(getByTestId("content-1")).toBeInTheDocument(); + expect(getByText(currentStep.title && currentStep.description)).toBeInTheDocument(); + }); + + test("Should render function content correctly", async () => { + currentStepNavigation = 2; + const { getByTestId, getByText } = renderComponent(); + const currentStep = steps[1]; + + expect(getByTestId("content-2")).toBeInTheDocument(); + expect(getByText(currentStep.title && currentStep.description)).toBeInTheDocument(); + }); + }); + + test("Should disable 'Next step' button if current step navigation is not enabled", async () => { + currentStepNavigation = 1; + const { nextLabel } = props; + const { getByText } = renderComponent(); + + expect(getByText(nextLabel)).toBeDisabled(); + }); + + test("Should handle when navigation is disabled", async () => { + const { queryByText, queryByTestId } = renderComponent({ disableNavigation: true }); + const { prevLabel, nextLabel, finishLabel } = props; + const stepComponent = queryByTestId("wizard-step-component"); + const stepInfo = { + title: queryByTestId("step-title"), + description: queryByTestId("step-description"), + }; + const buttons = { + prev: queryByText(prevLabel), + next: queryByText(nextLabel), + finish: queryByText(finishLabel), + }; + + expect(stepInfo.title && stepInfo.description).toBeInTheDocument(); + expect(stepComponent).not.toBeInTheDocument(); + expect(buttons.prev && buttons.next && buttons.finish).not.toBeInTheDocument(); + }); +}); From a308075bc39b77ed7059b0cae9d443d669a7bf98 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Wed, 30 Aug 2023 08:33:48 +0100 Subject: [PATCH 10/13] feat: 2fa backup codes (#10600) Co-authored-by: Peer Richelsen --- apps/web/components/auth/BackupCode.tsx | 29 +++++++ apps/web/components/auth/TwoFactor.tsx | 4 +- .../settings/DisableTwoFactorModal.tsx | 42 ++++++++-- .../settings/EnableTwoFactorModal.tsx | 83 +++++++++++++++++-- .../components/settings/TwoFactorAuthAPI.ts | 4 +- .../pages/api/auth/two-factor/totp/disable.ts | 27 +++++- .../pages/api/auth/two-factor/totp/setup.ts | 7 +- apps/web/pages/auth/login.tsx | 48 ++++++++--- apps/web/playwright/login.2fa.e2e.ts | 21 +++++ apps/web/public/static/locales/en/common.json | 7 ++ packages/features/auth/lib/ErrorCode.ts | 2 + .../features/auth/lib/next-auth-options.ts | 32 ++++++- packages/lib/test/builder.ts | 1 + .../migration.sql | 2 + packages/prisma/schema.prisma | 1 + packages/ui/components/form/inputs/Input.tsx | 6 +- 16 files changed, 280 insertions(+), 36 deletions(-) create mode 100644 apps/web/components/auth/BackupCode.tsx create mode 100644 packages/prisma/migrations/20230804153419_add_backup_codes/migration.sql diff --git a/apps/web/components/auth/BackupCode.tsx b/apps/web/components/auth/BackupCode.tsx new file mode 100644 index 0000000000..a9121d815e --- /dev/null +++ b/apps/web/components/auth/BackupCode.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { useFormContext } from "react-hook-form"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Label, TextField } from "@calcom/ui"; + +export default function TwoFactor({ center = true }) { + const { t } = useLocale(); + const methods = useFormContext(); + + return ( +
+ + +

{t("backup_code_instructions")}

+ + +
+ ); +} diff --git a/apps/web/components/auth/TwoFactor.tsx b/apps/web/components/auth/TwoFactor.tsx index e074639fe2..f46aa3b7e3 100644 --- a/apps/web/components/auth/TwoFactor.tsx +++ b/apps/web/components/auth/TwoFactor.tsx @@ -5,7 +5,7 @@ import { useFormContext } from "react-hook-form"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Label, Input } from "@calcom/ui"; -export default function TwoFactor({ center = true }) { +export default function TwoFactor({ center = true, autoFocus = true }) { const [value, onChange] = useState(""); const { t } = useLocale(); const methods = useFormContext(); @@ -40,7 +40,7 @@ export default function TwoFactor({ center = true }) { name={`2fa${index + 1}`} inputMode="decimal" {...digit} - autoFocus={index === 0} + autoFocus={autoFocus && index === 0} autoComplete="one-time-code" /> ))} diff --git a/apps/web/components/settings/DisableTwoFactorModal.tsx b/apps/web/components/settings/DisableTwoFactorModal.tsx index 385e775f32..46d49ce62a 100644 --- a/apps/web/components/settings/DisableTwoFactorModal.tsx +++ b/apps/web/components/settings/DisableTwoFactorModal.tsx @@ -5,6 +5,7 @@ import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Button, Dialog, DialogContent, DialogFooter, Form, PasswordField } from "@calcom/ui"; +import BackupCode from "@components/auth/BackupCode"; import TwoFactor from "@components/auth/TwoFactor"; import TwoFactorAuthAPI from "./TwoFactorAuthAPI"; @@ -20,6 +21,7 @@ interface DisableTwoFactorAuthModalProps { } interface DisableTwoFactorValues { + backupCode: string; totpCode: string; password: string; } @@ -33,11 +35,19 @@ const DisableTwoFactorAuthModal = ({ }: DisableTwoFactorAuthModalProps) => { const [isDisabling, setIsDisabling] = useState(false); const [errorMessage, setErrorMessage] = useState(null); + const [twoFactorLostAccess, setTwoFactorLostAccess] = useState(false); const { t } = useLocale(); const form = useForm(); - async function handleDisable({ totpCode, password }: DisableTwoFactorValues) { + const resetForm = (clearPassword = true) => { + if (clearPassword) form.setValue("password", ""); + form.setValue("backupCode", ""); + form.setValue("totpCode", ""); + setErrorMessage(null); + }; + + async function handleDisable({ password, totpCode, backupCode }: DisableTwoFactorValues) { if (isDisabling) { return; } @@ -45,8 +55,10 @@ const DisableTwoFactorAuthModal = ({ setErrorMessage(null); try { - const response = await TwoFactorAuthAPI.disable(password, totpCode); + const response = await TwoFactorAuthAPI.disable(password, totpCode, backupCode); if (response.status === 200) { + setTwoFactorLostAccess(false); + resetForm(); onDisable(); return; } @@ -54,12 +66,14 @@ const DisableTwoFactorAuthModal = ({ const body = await response.json(); if (body.error === ErrorCode.IncorrectPassword) { setErrorMessage(t("incorrect_password")); - } - if (body.error === ErrorCode.SecondFactorRequired) { + } else if (body.error === ErrorCode.SecondFactorRequired) { setErrorMessage(t("2fa_required")); - } - if (body.error === ErrorCode.IncorrectTwoFactorCode) { + } else if (body.error === ErrorCode.IncorrectTwoFactorCode) { setErrorMessage(t("incorrect_2fa")); + } else if (body.error === ErrorCode.IncorrectBackupCode) { + setErrorMessage(t("incorrect_backup_code")); + } else if (body.error === ErrorCode.MissingBackupCodes) { + setErrorMessage(t("missing_backup_codes")); } else { setErrorMessage(t("something_went_wrong")); } @@ -78,6 +92,7 @@ const DisableTwoFactorAuthModal = ({
{!disablePassword && ( )} - + {twoFactorLostAccess ? ( + + ) : ( + + )} {errorMessage &&

{errorMessage}

}
+ diff --git a/apps/web/components/settings/EnableTwoFactorModal.tsx b/apps/web/components/settings/EnableTwoFactorModal.tsx index 099558a8af..0ed406787f 100644 --- a/apps/web/components/settings/EnableTwoFactorModal.tsx +++ b/apps/web/components/settings/EnableTwoFactorModal.tsx @@ -5,7 +5,7 @@ import { useForm } from "react-hook-form"; import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode"; import { useCallbackRef } from "@calcom/lib/hooks/useCallbackRef"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { Button, Dialog, DialogContent, DialogFooter, Form, TextField } from "@calcom/ui"; +import { Button, Dialog, DialogContent, DialogFooter, Form, PasswordField, showToast } from "@calcom/ui"; import TwoFactor from "@components/auth/TwoFactor"; @@ -28,6 +28,7 @@ interface EnableTwoFactorModalProps { enum SetupStep { ConfirmPassword, + DisplayBackupCodes, DisplayQrCode, EnterTotpCode, } @@ -54,16 +55,25 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable const setupDescriptions = { [SetupStep.ConfirmPassword]: t("2fa_confirm_current_password"), + [SetupStep.DisplayBackupCodes]: t("backup_code_instructions"), [SetupStep.DisplayQrCode]: t("2fa_scan_image_or_use_code"), [SetupStep.EnterTotpCode]: t("2fa_enter_six_digit_code"), }; const [step, setStep] = useState(SetupStep.ConfirmPassword); const [password, setPassword] = useState(""); + const [backupCodes, setBackupCodes] = useState([]); + const [backupCodesUrl, setBackupCodesUrl] = useState(""); const [dataUri, setDataUri] = useState(""); const [secret, setSecret] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const [errorMessage, setErrorMessage] = useState(null); + const resetState = () => { + setPassword(""); + setErrorMessage(null); + setStep(SetupStep.ConfirmPassword); + }; + async function handleSetup(e: React.FormEvent) { e.preventDefault(); @@ -79,6 +89,15 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable const body = await response.json(); if (response.status === 200) { + setBackupCodes(body.backupCodes); + + // create backup codes download url + const textBlob = new Blob([body.backupCodes.map(formatBackupCode).join("\n")], { + type: "text/plain", + }); + if (backupCodesUrl) URL.revokeObjectURL(backupCodesUrl); + setBackupCodesUrl(URL.createObjectURL(textBlob)); + setDataUri(body.dataUri); setSecret(body.secret); setStep(SetupStep.DisplayQrCode); @@ -113,7 +132,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable const body = await response.json(); if (response.status === 200) { - onEnable(); + setStep(SetupStep.DisplayBackupCodes); return; } @@ -141,13 +160,18 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable } }, [form, handleEnableRef, totpCode]); + const formatBackupCode = (code: string) => `${code.slice(0, 5)}-${code.slice(5, 10)}`; + return ( - +
- + + <> +
+ {backupCodes.map((code) => ( +
{formatBackupCode(code)}
+ ))} +
+ +
@@ -186,9 +219,16 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
- + {step !== SetupStep.DisplayBackupCodes ? ( + + ) : null} + + + + + + diff --git a/apps/web/components/settings/TwoFactorAuthAPI.ts b/apps/web/components/settings/TwoFactorAuthAPI.ts index 35ef630575..1ea7792e87 100644 --- a/apps/web/components/settings/TwoFactorAuthAPI.ts +++ b/apps/web/components/settings/TwoFactorAuthAPI.ts @@ -19,10 +19,10 @@ const TwoFactorAuthAPI = { }); }, - async disable(password: string, code: string) { + async disable(password: string, code: string, backupCode: string) { return fetch("/api/auth/two-factor/totp/disable", { method: "POST", - body: JSON.stringify({ password, code }), + body: JSON.stringify({ password, code, backupCode }), headers: { "Content-Type": "application/json", }, diff --git a/apps/web/pages/api/auth/two-factor/totp/disable.ts b/apps/web/pages/api/auth/two-factor/totp/disable.ts index abc0835c4a..fecb75d92f 100644 --- a/apps/web/pages/api/auth/two-factor/totp/disable.ts +++ b/apps/web/pages/api/auth/two-factor/totp/disable.ts @@ -43,8 +43,30 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(400).json({ error: ErrorCode.IncorrectPassword }); } } - // if user has 2fa - if (user.twoFactorEnabled) { + + // if user has 2fa and using backup code + if (user.twoFactorEnabled && req.body.backupCode) { + if (!process.env.CALENDSO_ENCRYPTION_KEY) { + console.error("Missing encryption key; cannot proceed with backup code login."); + throw new Error(ErrorCode.InternalServerError); + } + + if (!user.backupCodes) { + return res.status(400).json({ error: ErrorCode.MissingBackupCodes }); + } + + const backupCodes = JSON.parse(symmetricDecrypt(user.backupCodes, process.env.CALENDSO_ENCRYPTION_KEY)); + + // check if user-supplied code matches one + const index = backupCodes.indexOf(req.body.backupCode.replaceAll("-", "")); + if (index === -1) { + return res.status(400).json({ error: ErrorCode.IncorrectBackupCode }); + } + + // we delete all stored backup codes at the end, no need to do this here + + // if user has 2fa and NOT using backup code, try totp + } else if (user.twoFactorEnabled) { if (!req.body.code) { return res.status(400).json({ error: ErrorCode.SecondFactorRequired }); // throw new Error(ErrorCode.SecondFactorRequired); @@ -82,6 +104,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) id: session.user.id, }, data: { + backupCodes: null, twoFactorEnabled: false, twoFactorSecret: null, }, diff --git a/apps/web/pages/api/auth/two-factor/totp/setup.ts b/apps/web/pages/api/auth/two-factor/totp/setup.ts index de63fcada6..a6fbed0391 100644 --- a/apps/web/pages/api/auth/two-factor/totp/setup.ts +++ b/apps/web/pages/api/auth/two-factor/totp/setup.ts @@ -1,3 +1,4 @@ +import crypto from "crypto"; import type { NextApiRequest, NextApiResponse } from "next"; import { authenticator } from "otplib"; import qrcode from "qrcode"; @@ -56,11 +57,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) // bytes without updating the sanity checks in the enable and login endpoints. const secret = authenticator.generateSecret(20); + // generate backup codes with 10 character length + const backupCodes = Array.from(Array(10), () => crypto.randomBytes(5).toString("hex")); + await prisma.user.update({ where: { id: session.user.id, }, data: { + backupCodes: symmetricEncrypt(JSON.stringify(backupCodes), process.env.CALENDSO_ENCRYPTION_KEY), twoFactorEnabled: false, twoFactorSecret: symmetricEncrypt(secret, process.env.CALENDSO_ENCRYPTION_KEY), }, @@ -70,5 +75,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const keyUri = authenticator.keyuri(name, "Cal", secret); const dataUri = await qrcode.toDataURL(keyUri); - return res.json({ secret, keyUri, dataUri }); + return res.json({ secret, keyUri, dataUri, backupCodes }); } diff --git a/apps/web/pages/auth/login.tsx b/apps/web/pages/auth/login.tsx index 4847ceb9fb..ca4e752e72 100644 --- a/apps/web/pages/auth/login.tsx +++ b/apps/web/pages/auth/login.tsx @@ -21,7 +21,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import prisma from "@calcom/prisma"; import { Alert, Button, EmailField, PasswordField } from "@calcom/ui"; -import { ArrowLeft } from "@calcom/ui/components/icon"; +import { ArrowLeft, Lock } from "@calcom/ui/components/icon"; import type { inferSSRProps } from "@lib/types/inferSSRProps"; import type { WithNonceProps } from "@lib/withNonce"; @@ -29,6 +29,7 @@ import withNonce from "@lib/withNonce"; import AddToHomescreen from "@components/AddToHomescreen"; import PageWrapper from "@components/PageWrapper"; +import BackupCode from "@components/auth/BackupCode"; import TwoFactor from "@components/auth/TwoFactor"; import AuthContainer from "@components/ui/AuthContainer"; @@ -39,6 +40,7 @@ interface LoginValues { email: string; password: string; totpCode: string; + backupCode: string; csrfToken: string; } export default function Login({ @@ -65,6 +67,7 @@ export default function Login({ const methods = useForm({ resolver: zodResolver(formSchema) }); const { register, formState } = methods; const [twoFactorRequired, setTwoFactorRequired] = useState(!!totpEmail || false); + const [twoFactorLostAccess, setTwoFactorLostAccess] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const errorMessages: { [key: string]: string } = { @@ -98,15 +101,35 @@ export default function Login({ ); const TwoFactorFooter = ( - + <> + + {!twoFactorLostAccess ? ( + + ) : null} + ); const ExternalTotpFooter = ( @@ -130,8 +153,9 @@ export default function Login({ if (!res) setErrorMessage(errorMessages[ErrorCode.InternalServerError]); // we're logged in! let's do a hard refresh to the desired url else if (!res.error) router.push(callbackUrl); - // reveal two factor input if required else if (res.error === ErrorCode.SecondFactorRequired) setTwoFactorRequired(true); + else if (res.error === ErrorCode.IncorrectBackupCode) setErrorMessage(t("incorrect_backup_code")); + else if (res.error === ErrorCode.MissingBackupCodes) setErrorMessage(t("missing_backup_codes")); // fallback if error not found else setErrorMessage(errorMessages[res.error] || t("something_went_wrong")); }; @@ -194,7 +218,7 @@ export default function Login({
- {twoFactorRequired && } + {twoFactorRequired ? !twoFactorLostAccess ? : : null} {errorMessage && }