diff --git a/components/auth/SAMLLogin.tsx b/components/auth/SAMLLogin.tsx new file mode 100644 index 0000000000..217f9bc396 --- /dev/null +++ b/components/auth/SAMLLogin.tsx @@ -0,0 +1,64 @@ +import { signIn } from "next-auth/react"; +import { Dispatch, SetStateAction } from "react"; +import { useFormContext } from "react-hook-form"; + +import { useLocale } from "@lib/hooks/useLocale"; +import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry"; +import { trpc } from "@lib/trpc"; + +import Button from "@components/ui/Button"; + +interface Props { + email: string; + samlTenantID: string; + samlProductID: string; + hostedCal: boolean; + setErrorMessage: Dispatch>; +} + +export default function SAMLLogin(props: Props) { + const { t } = useLocale(); + const methods = useFormContext(); + const telemetry = useTelemetry(); + + const mutation = trpc.useMutation("viewer.samlTenantProduct", { + onSuccess: async (data) => { + await signIn("saml", {}, { tenant: data.tenant, product: data.product }); + }, + onError: (err) => { + props.setErrorMessage(err.message); + }, + }); + + return ( +
+ +
+ ); +} diff --git a/components/auth/TwoFactor.tsx b/components/auth/TwoFactor.tsx new file mode 100644 index 0000000000..ea0429919f --- /dev/null +++ b/components/auth/TwoFactor.tsx @@ -0,0 +1,42 @@ +import React, { useEffect, useState } from "react"; +import useDigitInput from "react-digit-input"; +import { useFormContext } from "react-hook-form"; + +import { useLocale } from "@lib/hooks/useLocale"; + +import { Input } from "@components/form/fields"; + +export default function TwoFactor() { + const [value, onChange] = useState(""); + const { t } = useLocale(); + const methods = useFormContext(); + + const digits = useDigitInput({ + acceptedCharacters: /^[0-9]$/, + length: 6, + value, + onChange, + }); + + useEffect(() => { + if (value) methods.setValue("totpCode", value); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]); + + const className = "h-12 w-12 !text-xl text-center"; + + return ( +
+

{t("2fa_enabled_instructions")}

+ +
+ + + + + + +
+
+ ); +} diff --git a/components/form/fields.tsx b/components/form/fields.tsx index e3eb99cb05..ea5c0d5009 100644 --- a/components/form/fields.tsx +++ b/components/form/fields.tsx @@ -115,7 +115,17 @@ export const EmailInput = forwardRef(function }); export const EmailField = forwardRef(function EmailField(props, ref) { - return ; + return ( + + ); }); type TextAreaProps = Omit & { name: string }; diff --git a/package.json b/package.json index 2e54bfd35c..e9a994d6bd 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "qrcode": "^1.5.0", "react": "^17.0.2", "react-date-picker": "^8.3.6", + "react-digit-input": "^2.1.0", "react-dom": "^17.0.2", "react-easy-crop": "^3.5.2", "react-hook-form": "^7.20.4", diff --git a/pages/auth/error.tsx b/pages/auth/error.tsx index 0571b3e550..ad3f8acaf7 100644 --- a/pages/auth/error.tsx +++ b/pages/auth/error.tsx @@ -5,7 +5,8 @@ import { useRouter } from "next/router"; import { useLocale } from "@lib/hooks/useLocale"; -import { HeadSeo } from "@components/seo/head-seo"; +import AuthContainer from "@components/ui/AuthContainer"; +import Button from "@components/ui/Button"; import { ssrInit } from "@server/lib/ssr"; @@ -15,40 +16,26 @@ export default function Error() { const { error } = router.query; return ( -
- -
- -
-
-
- -
-
- -
-

{t("error_during_login")}

-
-
-
-
- - - {t("go_back_login")} - - + +
+
+ +
+
+ +
+

{t("error_during_login")}

-
+
+ + + +
+ ); } diff --git a/pages/auth/login.tsx b/pages/auth/login.tsx index 1d9b8bf1a9..6d380bf896 100644 --- a/pages/auth/login.tsx +++ b/pages/auth/login.tsx @@ -1,25 +1,37 @@ +import { ArrowLeftIcon } from "@heroicons/react/solid"; +import classNames from "classnames"; import { GetServerSidePropsContext } from "next"; import { getCsrfToken, signIn } from "next-auth/react"; import Link from "next/link"; import { useRouter } from "next/router"; import { useState } from "react"; +import { useForm } from "react-hook-form"; import { ErrorCode, getSession } from "@lib/auth"; import { WEBSITE_URL } from "@lib/config/constants"; import { useLocale } from "@lib/hooks/useLocale"; import { isSAMLLoginEnabled, hostedCal, samlTenantID, samlProductID } from "@lib/saml"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry"; -import { trpc } from "@lib/trpc"; import { inferSSRProps } from "@lib/types/inferSSRProps"; import AddToHomescreen from "@components/AddToHomescreen"; -import { EmailField, PasswordField, TextField } from "@components/form/fields"; +import SAMLLogin from "@components/auth/SAMLLogin"; +import TwoFactor from "@components/auth/TwoFactor"; +import { EmailField, PasswordField, Form } from "@components/form/fields"; +import { Alert } from "@components/ui/Alert"; import AuthContainer from "@components/ui/AuthContainer"; import Button from "@components/ui/Button"; import { IS_GOOGLE_LOGIN_ENABLED } from "@server/lib/constants"; import { ssrInit } from "@server/lib/ssr"; +interface LoginValues { + email: string; + password: string; + totpCode: string; + csrfToken: string; +} + export default function Login({ csrfToken, isGoogleLoginEnabled, @@ -30,14 +42,13 @@ export default function Login({ }: inferSSRProps) { const { t } = useLocale(); const router = useRouter(); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [code, setCode] = useState(""); - const [isSubmitting, setIsSubmitting] = useState(false); - const [secondFactorRequired, setSecondFactorRequired] = useState(false); + const form = useForm(); + + const [twoFactorRequired, setTwoFactorRequired] = useState(false); const [errorMessage, setErrorMessage] = useState(null); + const errorMessages: { [key: string]: string } = { - [ErrorCode.SecondFactorRequired]: t("2fa_enabled_instructions"), + // [ErrorCode.SecondFactorRequired]: t("2fa_enabled_instructions"), [ErrorCode.IncorrectPassword]: `${t("incorrect_password")} ${t("please_try_again")}`, [ErrorCode.UserNotFound]: t("no_account_exists"), [ErrorCode.IncorrectTwoFactorCode]: `${t("incorrect_2fa_code")} ${t("please_try_again")}`, @@ -49,186 +60,125 @@ export default function Login({ const callbackUrl = typeof router.query?.callbackUrl === "string" ? router.query.callbackUrl : "/"; - async function handleSubmit(e: React.SyntheticEvent) { - e.preventDefault(); + const LoginFooter = ( + + {t("dont_have_an_account")}{" "} + + {t("create_an_account")} + + + ); - if (isSubmitting) { - return; - } - - setIsSubmitting(true); - setErrorMessage(null); - - try { - const response = await signIn<"credentials">("credentials", { - redirect: false, - email, - password, - totpCode: code, - callbackUrl, - }); - if (!response) { - throw new Error("Received empty response from next auth"); - } - - if (!response.error) { - // we're logged in! let's do a hard refresh to the desired url - window.location.replace(callbackUrl); - return; - } - - if (response.error === ErrorCode.SecondFactorRequired) { - setSecondFactorRequired(true); - setErrorMessage(errorMessages[ErrorCode.SecondFactorRequired]); - } else { - setErrorMessage(errorMessages[response.error] || t("something_went_wrong")); - } - setIsSubmitting(false); - } catch (e) { - setErrorMessage(t("something_went_wrong")); - setIsSubmitting(false); - } - } - - const mutation = trpc.useMutation("viewer.samlTenantProduct", { - onSuccess: (data) => { - signIn("saml", {}, { tenant: data.tenant, product: data.product }); - }, - onError: (err) => { - setErrorMessage(err.message); - }, - }); + const TwoFactorFooter = ( + + ); return ( <> - {t("dont_have_an_account")} {/* replace this with your account creation flow */} - - {t("create_an_account")} - - - }> -
- -
- -
- + { + signIn<"credentials">("credentials", { ...values, callbackUrl, redirect: false }) + .then((res) => { + if (!res) setErrorMessage(errorMessages[ErrorCode.InternalServerError]); + // we're logged in! let's do a hard refresh to the desired url + else if (!res.error) window.location.replace(callbackUrl); + // reveal two factor input if required + else if (res.error === ErrorCode.SecondFactorRequired) setTwoFactorRequired(true); + // fallback if error not found + else setErrorMessage(errorMessages[res.error] || t("something_went_wrong")); + }) + .catch(() => setErrorMessage(errorMessages[ErrorCode.InternalServerError])); + }}> + + +
+ +
+ + setEmail(e.currentTarget.value)} + {...form.register("password")} />
-
- - setPassword(e.currentTarget.value)} - /> -
- - {secondFactorRequired && ( - setCode(e.currentTarget.value)} - /> - )} + {twoFactorRequired && } + {errorMessage && }
- -
- - {errorMessage &&

{errorMessage}

} - - {isGoogleLoginEnabled && ( -
- )} - {isSAMLLoginEnabled && ( -
- -
+ {!twoFactorRequired && ( + <> + {isGoogleLoginEnabled && ( +
+ +
+ )} + {isSAMLLoginEnabled && ( + + )} + )} - ); diff --git a/yarn.lock b/yarn.lock index 039d1b83b1..151dbd9194 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9635,6 +9635,11 @@ react-date-picker@^8.3.6: react-fit "^1.0.3" update-input-width "^1.2.2" +react-digit-input@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/react-digit-input/-/react-digit-input-2.1.0.tgz#8b0be6d3ea247fd361855483f21d0aafba341196" + integrity sha512-pGv0CtSmu3Mf4cD79LoYtJI7Wq4dpPiLiY1wvKsNaR+X2sJyk1ETiIxjq6G8i+XJqNXExM6vuytzDqblkkSaFw== + react-dom@^17.0.2: version "17.0.2" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23"