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>
This commit is contained in:
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) {
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 };

View File

@ -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",

View File

@ -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 (
<div
className="fixed z-50 inset-0 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<HeadSeo title={t("error")} description={t("error")} />
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<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">
<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>
<AuthContainer title="" description="">
<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>
<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 { 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<typeof getServerSideProps>) {
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<LoginValues>();
const [twoFactorRequired, setTwoFactorRequired] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(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 = (
<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) {
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 = (
<Button
onClick={() => {
setTwoFactorRequired(false);
form.setValue("totpCode", "");
}}
StartIcon={ArrowLeftIcon}
color="minimal">
{t("go_back")}
</Button>
);
return (
<>
<AuthContainer
title={t("login")}
description={t("login")}
loading={isSubmitting}
loading={form.formState.isSubmitting}
showLogo
heading={t("sign_in_account")}
footerText={
<>
{t("dont_have_an_account")} {/* replace this with your account creation flow */}
<a href={`${WEBSITE_URL}/signup`} className="font-medium text-neutral-900">
{t("create_an_account")}
</a>
</>
}>
<form className="space-y-6" onSubmit={handleSubmit}>
<input name="csrfToken" type="hidden" defaultValue={csrfToken || undefined} hidden />
<div>
<label htmlFor="email" className="block text-sm font-medium text-neutral-700">
{t("email_address")}
</label>
<div className="mt-1">
<EmailField
id="email"
name="email"
placeholder="john.doe@example.com"
heading={twoFactorRequired ? t("2fa_code") : t("sign_in_account")}
footerText={twoFactorRequired ? TwoFactorFooter : LoginFooter}>
<Form
form={form}
className="space-y-6"
handleSubmit={(values) => {
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]));
}}>
<input defaultValue={csrfToken || undefined} type="hidden" hidden {...form.register("csrfToken")} />
<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
value={email}
onInput={(e) => setEmail(e.currentTarget.value)}
{...form.register("password")}
/>
</div>
</div>
<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"
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)}
/>
)}
{twoFactorRequired && <TwoFactor />}
{errorMessage && <Alert severity="error" title={errorMessage} />}
<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
color="secondary"
className="flex justify-center w-full"
data-testid={"google"}
onClick={async (event) => {
event.preventDefault();
// track Google logins. Without personal data/payload
telemetry.withJitsu((jitsu) =>
jitsu.track(telemetryEventTypes.googleLogin, collectPageParameters())
);
await signIn("google");
}}>
{" "}
{t("signin_with_google")}
type="submit"
disabled={form.formState.isSubmitting}>
{twoFactorRequired ? t("submit") : t("sign_in")}
</Button>
</div>
)}
{isSAMLLoginEnabled && (
<div style={{ marginTop: "12px" }}>
<Button
color="secondary"
data-testid={"saml"}
className="flex justify-center w-full"
onClick={async (event) => {
event.preventDefault();
</Form>
// track SAML logins. Without personal data/payload
telemetry.withJitsu((jitsu) =>
jitsu.track(telemetryEventTypes.samlLogin, collectPageParameters())
);
if (!hostedCal) {
await signIn("saml", {}, { tenant: samlTenantID, product: samlProductID });
} else {
if (email.length === 0) {
setErrorMessage(t("saml_email_required"));
return;
}
// hosted solution, fetch tenant and product from the backend
mutation.mutate({
email,
});
}
}}>
{t("signin_with_saml")}
</Button>
</div>
{!twoFactorRequired && (
<>
{isGoogleLoginEnabled && (
<div className="mt-5">
<Button
color="secondary"
className="flex justify-center w-full"
data-testid={"google"}
onClick={async (e) => {
e.preventDefault();
// track Google logins. Without personal data/payload
telemetry.withJitsu((jitsu) =>
jitsu.track(telemetryEventTypes.googleLogin, collectPageParameters())
);
await signIn("google");
}}>
{t("signin_with_google")}
</Button>
</div>
)}
{isSAMLLoginEnabled && (
<SAMLLogin
email={form.getValues("email")}
samlTenantID={samlTenantID}
samlProductID={samlProductID}
hostedCal={hostedCal}
setErrorMessage={setErrorMessage}
/>
)}
</>
)}
</AuthContainer>
<AddToHomescreen />
</>
);

View File

@ -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"