Better 2FA Interface (#1707)

* - added TwoFactor component
- added react-digit-input package
- added SAMLLogin component
- upgraded auth/logic to react-hook-form
- fixed EmailField to match other ___Field components to include Label
- cleaned up login logic

* upgraded error component

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
pull/1704/head
Jamie Pine 2022-02-04 12:30:36 -08:00 committed by GitHub
parent ae5d5e1261
commit d0a6d6a6e6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 258 additions and 199 deletions

View File

@ -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<SetStateAction<string | null>>;
}
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 (
<div className="mt-5">
<Button
color="secondary"
data-testid={"saml"}
className="flex justify-center w-full"
onClick={async (event) => {
event.preventDefault();
// track Google logins. Without personal data/payload
telemetry.withJitsu((jitsu) =>
jitsu.track(telemetryEventTypes.googleLogin, collectPageParameters())
);
if (!props.hostedCal) {
await signIn("saml", {}, { tenant: props.samlTenantID, product: props.samlProductID });
} else {
if (props.email.length === 0) {
props.setErrorMessage(t("saml_email_required"));
return;
}
// hosted solution, fetch tenant and product from the backend
mutation.mutate({
email: methods.getValues("email"),
});
}
}}>
{t("signin_with_saml")}
</Button>
</div>
);
}

View File

@ -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 (
<div className="max-w-sm mx-auto !mt-0">
<p className="mb-4 text-sm text-gray-500">{t("2fa_enabled_instructions")}</p>
<input hidden type="hidden" value={value} {...methods.register("totpCode")} />
<div className="flex flex-row space-x-1">
<Input className={className} name="2fa1" inputMode="decimal" {...digits[0]} autoFocus />
<Input className={className} name="2fa2" inputMode="decimal" {...digits[1]} />
<Input className={className} name="2fa3" inputMode="decimal" {...digits[2]} />
<Input className={className} name="2fa4" inputMode="decimal" {...digits[3]} />
<Input className={className} name="2fa5" inputMode="decimal" {...digits[4]} />
<Input className={className} name="2fa6" inputMode="decimal" {...digits[5]} />
</div>
</div>
);
}

View File

@ -115,7 +115,17 @@ export const EmailInput = forwardRef<HTMLInputElement, InputFieldProps>(function
}); });
export const EmailField = forwardRef<HTMLInputElement, InputFieldProps>(function EmailField(props, ref) { export const EmailField = forwardRef<HTMLInputElement, InputFieldProps>(function EmailField(props, ref) {
return <EmailInput ref={ref} {...props} />; return (
<InputField
ref={ref}
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
inputMode="email"
{...props}
/>
);
}); });
type TextAreaProps = Omit<JSX.IntrinsicElements["textarea"], "name"> & { name: string }; type TextAreaProps = Omit<JSX.IntrinsicElements["textarea"], "name"> & { name: string };

View File

@ -86,6 +86,7 @@
"qrcode": "^1.5.0", "qrcode": "^1.5.0",
"react": "^17.0.2", "react": "^17.0.2",
"react-date-picker": "^8.3.6", "react-date-picker": "^8.3.6",
"react-digit-input": "^2.1.0",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-easy-crop": "^3.5.2", "react-easy-crop": "^3.5.2",
"react-hook-form": "^7.20.4", "react-hook-form": "^7.20.4",

View File

@ -5,7 +5,8 @@ import { useRouter } from "next/router";
import { useLocale } from "@lib/hooks/useLocale"; 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"; import { ssrInit } from "@server/lib/ssr";
@ -15,40 +16,26 @@ export default function Error() {
const { error } = router.query; const { error } = router.query;
return ( return (
<div <AuthContainer title="" description="">
className="fixed z-50 inset-0 overflow-y-auto" <div>
aria-labelledby="modal-title" <div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
role="dialog" <XIcon className="h-6 w-6 text-red-600" />
aria-modal="true"> </div>
<HeadSeo title={t("error")} description={t("error")} /> <div className="mt-3 text-center sm:mt-5">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"> <h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true"> {error}
&#8203; </h3>
</span> <div className="mt-2">
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6"> <p className="text-sm text-gray-500">{t("error_during_login")}</p>
<div>
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
<XIcon className="h-6 w-6 text-red-600" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
{error}
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500">{t("error_during_login")}</p>
</div>
</div>
</div>
<div className="mt-5 sm:mt-6">
<Link href="/auth/login">
<a className="inline-flex justify-center w-full rounded-sm border border-transparent shadow-sm px-4 py-2 bg-neutral-900 text-base font-medium text-white hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500 sm:text-sm">
{t("go_back_login")}
</a>
</Link>
</div> </div>
</div> </div>
</div> </div>
</div> <div className="mt-5 sm:mt-6">
<Link href="/auth/login">
<Button className="w-full flex justify-center">{t("go_back_login")}</Button>
</Link>
</div>
</AuthContainer>
); );
} }

View File

@ -1,25 +1,37 @@
import { ArrowLeftIcon } from "@heroicons/react/solid";
import classNames from "classnames";
import { GetServerSidePropsContext } from "next"; import { GetServerSidePropsContext } from "next";
import { getCsrfToken, signIn } from "next-auth/react"; import { getCsrfToken, signIn } from "next-auth/react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form";
import { ErrorCode, getSession } from "@lib/auth"; import { ErrorCode, getSession } from "@lib/auth";
import { WEBSITE_URL } from "@lib/config/constants"; import { WEBSITE_URL } from "@lib/config/constants";
import { useLocale } from "@lib/hooks/useLocale"; import { useLocale } from "@lib/hooks/useLocale";
import { isSAMLLoginEnabled, hostedCal, samlTenantID, samlProductID } from "@lib/saml"; import { isSAMLLoginEnabled, hostedCal, samlTenantID, samlProductID } from "@lib/saml";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
import { trpc } from "@lib/trpc";
import { inferSSRProps } from "@lib/types/inferSSRProps"; import { inferSSRProps } from "@lib/types/inferSSRProps";
import AddToHomescreen from "@components/AddToHomescreen"; 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 AuthContainer from "@components/ui/AuthContainer";
import Button from "@components/ui/Button"; import Button from "@components/ui/Button";
import { IS_GOOGLE_LOGIN_ENABLED } from "@server/lib/constants"; import { IS_GOOGLE_LOGIN_ENABLED } from "@server/lib/constants";
import { ssrInit } from "@server/lib/ssr"; import { ssrInit } from "@server/lib/ssr";
interface LoginValues {
email: string;
password: string;
totpCode: string;
csrfToken: string;
}
export default function Login({ export default function Login({
csrfToken, csrfToken,
isGoogleLoginEnabled, isGoogleLoginEnabled,
@ -30,14 +42,13 @@ export default function Login({
}: inferSSRProps<typeof getServerSideProps>) { }: inferSSRProps<typeof getServerSideProps>) {
const { t } = useLocale(); const { t } = useLocale();
const router = useRouter(); const router = useRouter();
const [email, setEmail] = useState(""); const form = useForm<LoginValues>();
const [password, setPassword] = useState("");
const [code, setCode] = useState(""); const [twoFactorRequired, setTwoFactorRequired] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [secondFactorRequired, setSecondFactorRequired] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const errorMessages: { [key: string]: string } = { 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.IncorrectPassword]: `${t("incorrect_password")} ${t("please_try_again")}`,
[ErrorCode.UserNotFound]: t("no_account_exists"), [ErrorCode.UserNotFound]: t("no_account_exists"),
[ErrorCode.IncorrectTwoFactorCode]: `${t("incorrect_2fa_code")} ${t("please_try_again")}`, [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 : "/"; const callbackUrl = typeof router.query?.callbackUrl === "string" ? router.query.callbackUrl : "/";
async function handleSubmit(e: React.SyntheticEvent) { const LoginFooter = (
e.preventDefault(); <span>
{t("dont_have_an_account")}{" "}
<a href={`${WEBSITE_URL}/signup`} className="font-medium text-neutral-900">
{t("create_an_account")}
</a>
</span>
);
if (isSubmitting) { const TwoFactorFooter = (
return; <Button
} onClick={() => {
setTwoFactorRequired(false);
setIsSubmitting(true); form.setValue("totpCode", "");
setErrorMessage(null); }}
StartIcon={ArrowLeftIcon}
try { color="minimal">
const response = await signIn<"credentials">("credentials", { {t("go_back")}
redirect: false, </Button>
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);
},
});
return ( return (
<> <>
<AuthContainer <AuthContainer
title={t("login")} title={t("login")}
description={t("login")} description={t("login")}
loading={isSubmitting} loading={form.formState.isSubmitting}
showLogo showLogo
heading={t("sign_in_account")} heading={twoFactorRequired ? t("2fa_code") : t("sign_in_account")}
footerText={ footerText={twoFactorRequired ? TwoFactorFooter : LoginFooter}>
<> <Form
{t("dont_have_an_account")} {/* replace this with your account creation flow */} form={form}
<a href={`${WEBSITE_URL}/signup`} className="font-medium text-neutral-900"> className="space-y-6"
{t("create_an_account")} handleSubmit={(values) => {
</a> signIn<"credentials">("credentials", { ...values, callbackUrl, redirect: false })
</> .then((res) => {
}> if (!res) setErrorMessage(errorMessages[ErrorCode.InternalServerError]);
<form className="space-y-6" onSubmit={handleSubmit}> // we're logged in! let's do a hard refresh to the desired url
<input name="csrfToken" type="hidden" defaultValue={csrfToken || undefined} hidden /> else if (!res.error) window.location.replace(callbackUrl);
<div> // reveal two factor input if required
<label htmlFor="email" className="block text-sm font-medium text-neutral-700"> else if (res.error === ErrorCode.SecondFactorRequired) setTwoFactorRequired(true);
{t("email_address")} // fallback if error not found
</label> else setErrorMessage(errorMessages[res.error] || t("something_went_wrong"));
<div className="mt-1"> })
<EmailField .catch(() => setErrorMessage(errorMessages[ErrorCode.InternalServerError]));
id="email" }}>
name="email" <input defaultValue={csrfToken || undefined} type="hidden" hidden {...form.register("csrfToken")} />
placeholder="john.doe@example.com"
<div className={classNames("space-y-6", { hidden: twoFactorRequired })}>
<EmailField
id="email"
label={t("email_address")}
placeholder="john.doe@example.com"
required
{...form.register("email")}
/>
<div className="relative">
<div className="absolute right-0 -top-[2px]">
<Link href="/auth/forgot-password">
<a tabIndex={-1} className="text-sm font-medium text-primary-600">
{t("forgot")}
</a>
</Link>
</div>
<PasswordField
id="password"
type="password"
autoComplete="current-password"
required required
value={email} {...form.register("password")}
onInput={(e) => setEmail(e.currentTarget.value)}
/> />
</div> </div>
</div> </div>
<div className="relative"> {twoFactorRequired && <TwoFactor />}
<div className="absolute right-0 -top-[2px]">
<Link href="/auth/forgot-password">
<a tabIndex={-1} className="text-sm font-medium text-primary-600">
{t("forgot")}
</a>
</Link>
</div>
<PasswordField
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onInput={(e) => setPassword(e.currentTarget.value)}
/>
</div>
{secondFactorRequired && (
<TextField
className="mt-1"
id="totpCode"
name={t("2fa_code")}
type="text"
maxLength={6}
minLength={6}
inputMode="numeric"
value={code}
onInput={(e) => setCode(e.currentTarget.value)}
/>
)}
{errorMessage && <Alert severity="error" title={errorMessage} />}
<div className="flex space-y-2"> <div className="flex space-y-2">
<Button className="flex justify-center w-full" type="submit" disabled={isSubmitting}>
{t("sign_in")}
</Button>
</div>
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
</form>
{isGoogleLoginEnabled && (
<div style={{ marginTop: "12px" }}>
<Button <Button
color="secondary"
className="flex justify-center w-full" className="flex justify-center w-full"
data-testid={"google"} type="submit"
onClick={async (event) => { disabled={form.formState.isSubmitting}>
event.preventDefault(); {twoFactorRequired ? t("submit") : t("sign_in")}
// track Google logins. Without personal data/payload
telemetry.withJitsu((jitsu) =>
jitsu.track(telemetryEventTypes.googleLogin, collectPageParameters())
);
await signIn("google");
}}>
{" "}
{t("signin_with_google")}
</Button> </Button>
</div> </div>
)} </Form>
{isSAMLLoginEnabled && (
<div style={{ marginTop: "12px" }}>
<Button
color="secondary"
data-testid={"saml"}
className="flex justify-center w-full"
onClick={async (event) => {
event.preventDefault();
// track SAML logins. Without personal data/payload {!twoFactorRequired && (
telemetry.withJitsu((jitsu) => <>
jitsu.track(telemetryEventTypes.samlLogin, collectPageParameters()) {isGoogleLoginEnabled && (
); <div className="mt-5">
<Button
if (!hostedCal) { color="secondary"
await signIn("saml", {}, { tenant: samlTenantID, product: samlProductID }); className="flex justify-center w-full"
} else { data-testid={"google"}
if (email.length === 0) { onClick={async (e) => {
setErrorMessage(t("saml_email_required")); e.preventDefault();
return; // track Google logins. Without personal data/payload
} telemetry.withJitsu((jitsu) =>
jitsu.track(telemetryEventTypes.googleLogin, collectPageParameters())
// hosted solution, fetch tenant and product from the backend );
mutation.mutate({ await signIn("google");
email, }}>
}); {t("signin_with_google")}
} </Button>
}}> </div>
{t("signin_with_saml")} )}
</Button> {isSAMLLoginEnabled && (
</div> <SAMLLogin
email={form.getValues("email")}
samlTenantID={samlTenantID}
samlProductID={samlProductID}
hostedCal={hostedCal}
setErrorMessage={setErrorMessage}
/>
)}
</>
)} )}
</AuthContainer> </AuthContainer>
<AddToHomescreen /> <AddToHomescreen />
</> </>
); );

View File

@ -9635,6 +9635,11 @@ react-date-picker@^8.3.6:
react-fit "^1.0.3" react-fit "^1.0.3"
update-input-width "^1.2.2" 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: react-dom@^17.0.2:
version "17.0.2" version "17.0.2"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23"