Improved authentication screens (Login/Logout/Forgot Password) (#1627)

This commit is contained in:
Jamie Pine 2022-01-27 02:16:20 -08:00 committed by GitHub
parent 5aaf702e2b
commit e06edadda5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 218 additions and 229 deletions

View file

@ -100,18 +100,15 @@ export const PasswordField = forwardRef<HTMLInputElement, InputFieldProps>(funct
return <InputField type="password" placeholder="•••••••••••••" ref={ref} {...props} />; return <InputField type="password" placeholder="•••••••••••••" ref={ref} {...props} />;
}); });
export const EmailInput = forwardRef<HTMLInputElement, JSX.IntrinsicElements["input"]>(function EmailInput( export const EmailInput = forwardRef<HTMLInputElement, InputFieldProps>(function EmailInput(props, ref) {
props,
ref
) {
return ( return (
<input <Input
ref={ref}
type="email" type="email"
autoCapitalize="none" autoCapitalize="none"
autoComplete="email" autoComplete="email"
autoCorrect="off" autoCorrect="off"
inputMode="email" inputMode="email"
ref={ref}
{...props} {...props}
/> />
); );

View file

@ -0,0 +1,40 @@
import React from "react";
import Loader from "@components/Loader";
import { HeadSeo } from "@components/seo/head-seo";
interface Props {
title: string;
description: string;
footerText?: React.ReactNode | string;
showLogo?: boolean;
heading?: string;
loading?: boolean;
}
export default function AuthContainer(props: React.PropsWithChildren<Props>) {
return (
<div className="flex flex-col justify-center min-h-screen py-12 bg-neutral-50 sm:px-6 lg:px-8">
<HeadSeo title={props.title} description={props.description} />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
{props.showLogo && (
<img className="h-6 mx-auto" src="/calendso-logo-white-word.svg" alt="Cal.com Logo" />
)}
{props.heading && (
<h2 className="mt-6 text-3xl font-bold text-center font-cal text-neutral-900">{props.heading}</h2>
)}
</div>
{props.loading && (
<div className="absolute z-50 flex items-center w-full h-screen bg-gray-50">
<Loader />
</div>
)}
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="px-4 py-8 mx-2 bg-white border rounded-sm sm:px-10 border-neutral-200">
{props.children}
</div>
<div className="mt-4 text-sm text-center text-neutral-600">{props.footerText}</div>
</div>
</div>
);
}

View file

@ -1,13 +1,14 @@
import debounce from "lodash/debounce"; import debounce from "lodash/debounce";
import { GetServerSidePropsContext } from "next"; import { GetServerSidePropsContext } from "next";
import { getCsrfToken } from "next-auth/react"; import { getCsrfToken } from "next-auth/react";
import Link from "next/link";
import React, { SyntheticEvent } from "react"; import React, { SyntheticEvent } from "react";
import { getSession } from "@lib/auth"; import { getSession } from "@lib/auth";
import { useLocale } from "@lib/hooks/useLocale"; import { useLocale } from "@lib/hooks/useLocale";
import { EmailField } from "@components/form/fields"; import { EmailField } from "@components/form/fields";
import { HeadSeo } from "@components/seo/head-seo"; import AuthContainer from "@components/ui/AuthContainer";
import Button from "@components/ui/Button"; import Button from "@components/ui/Button";
export default function ForgotPassword({ csrfToken }: { csrfToken: string }) { export default function ForgotPassword({ csrfToken }: { csrfToken: string }) {
@ -72,64 +73,56 @@ export default function ForgotPassword({ csrfToken }: { csrfToken: string }) {
const Success = () => { const Success = () => {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<h2 className="mt-6 text-3xl font-extrabold text-center text-gray-900">{t("done")}</h2> <p className="text-center">{t("check_email_reset_password")}</p>
<p>{t("check_email_reset_password")}</p> {error && <p className="text-center text-red-600">{error.message}</p>}
{error && <p className="text-red-600">{error.message}</p>}
</div> </div>
); );
}; };
return ( return (
<div className="flex flex-col justify-center min-h-screen py-12 bg-gray-50 sm:px-6 lg:px-8"> <AuthContainer
<HeadSeo title={t("forgot_password")} description={t("request_password_reset")} /> title={t("forgot_password")}
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md"> description={t("request_password_reset")}
<div className="px-4 pt-3 pb-8 mx-2 space-y-6 bg-white rounded-lg shadow sm:px-10"> heading={t("forgot_password")}
{success && <Success />} footerText={
{!success && ( <>
<> {t("already_have_an_account")}{" "}
<div className="space-y-6"> <Link href="/auth/login">
<h2 className="mt-6 text-3xl font-extrabold text-center text-gray-900 font-cal"> <a className="font-medium text-neutral-900">{t("login_instead")}</a>
{t("forgot_password")} </Link>
</h2> </>
<p className="text-sm text-gray-500">{t("reset_instructions")}</p> }>
{error && <p className="text-red-600">{error.message}</p>} {success && <Success />}
</div> {!success && (
<form className="space-y-6" onSubmit={handleSubmit} action="#"> <>
<input name="csrfToken" type="hidden" defaultValue={csrfToken} hidden /> <div className="space-y-6">
<p className="mb-4 text-sm text-gray-500">{t("reset_instructions")}</p>
<EmailField {error && <p className="text-red-600">{error.message}</p>}
onChange={handleChange} </div>
id="email" <form className="space-y-6" onSubmit={handleSubmit} action="#">
name="email" <input name="csrfToken" type="hidden" defaultValue={csrfToken} hidden />
label={t("email_address")} <EmailField
placeholder="john.doe@example.com" onChange={handleChange}
required id="email"
/> name="email"
<div className="space-y-2"> label={t("email_address")}
<Button placeholder="john.doe@example.com"
className="justify-center w-full" required
type="submit" />
disabled={loading} <div className="space-y-2">
aria-label={t("request_password_reset")} <Button
loading={loading}> className="justify-center w-full"
{t("request_password_reset")} type="submit"
</Button> disabled={loading}
aria-label={t("request_password_reset")}
<Button loading={loading}>
href="/auth/login" {t("request_password_reset")}
color="minimal" </Button>
role="button" </div>
aria-label={t("login_instead")} </form>
className="justify-center w-full"> </>
{t("login_instead")} )}
</Button> </AuthContainer>
</div>
</form>
</>
)}
</div>
</div>
</div>
); );
} }

View file

@ -12,9 +12,9 @@ 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 Loader from "@components/Loader"; import { EmailField, PasswordField, TextField } from "@components/form/fields";
import { EmailInput } from "@components/form/fields"; import AuthContainer from "@components/ui/AuthContainer";
import { HeadSeo } from "@components/seo/head-seo"; 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";
@ -97,150 +97,123 @@ export default function Login({
}); });
return ( return (
<div className="flex flex-col justify-center min-h-screen py-12 bg-neutral-50 sm:px-6 lg:px-8"> <>
<HeadSeo title={t("login")} description={t("login")} /> <AuthContainer
title={t("login")}
{isSubmitting && ( description={t("login")}
<div className="absolute z-50 flex items-center w-full h-screen bg-gray-50"> loading={isSubmitting}
<Loader /> showLogo
</div> heading={t("sign_in_account")}
)} footerText={
<>
<div className="sm:mx-auto sm:w-full sm:max-w-md"> {t("dont_have_an_account")} {/* replace this with your account creation flow */}
<img className="h-6 mx-auto" src="/calendso-logo-white-word.svg" alt="Cal.com Logo" /> <a href={`${WEBSITE_URL}/signup`} className="font-medium text-neutral-900">
<h2 className="mt-6 text-3xl font-bold text-center font-cal text-neutral-900"> {t("create_an_account")}
{t("sign_in_account")} </a>
</h2> </>
</div> }>
<form className="space-y-6" onSubmit={handleSubmit}>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md"> <input name="csrfToken" type="hidden" defaultValue={csrfToken || undefined} hidden />
<div className="px-4 py-8 mx-2 bg-white border rounded-sm sm:px-10 border-neutral-200"> <div>
<form className="space-y-6" onSubmit={handleSubmit}> <label htmlFor="email" className="block text-sm font-medium text-neutral-700">
<input name="csrfToken" type="hidden" defaultValue={csrfToken || undefined} hidden /> {t("email_address")}
<div> </label>
<label htmlFor="email" className="block text-sm font-medium text-neutral-700"> <div className="mt-1">
{t("email_address")} <EmailField
</label> id="email"
<div className="mt-1"> name="email"
<EmailInput placeholder="john.doe@example.com"
id="email" required
name="email" value={email}
required onInput={(e) => setEmail(e.currentTarget.value)}
value={email} />
onInput={(e) => setEmail(e.currentTarget.value)}
className="block w-full px-3 py-2 placeholder-gray-400 border rounded-sm shadow-sm appearance-none border-neutral-300 focus:outline-none focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm"
/>
</div>
</div> </div>
</div>
<div> <div className="relative">
<div className="flex"> <div className="absolute right-0 -top-[2px]">
<div className="w-1/2"> <Link href="/auth/forgot-password">
<label htmlFor="password" className="block text-sm font-medium text-neutral-700"> <a tabIndex={-1} className="text-sm font-medium text-primary-600">
{t("password")} {t("forgot")}
</label> </a>
</div> </Link>
<div className="w-1/2 text-right">
<Link href="/auth/forgot-password">
<a tabIndex={-1} className="text-sm font-medium text-primary-600">
{t("forgot")}
</a>
</Link>
</div>
</div>
<div className="mt-1">
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onInput={(e) => setPassword(e.currentTarget.value)}
className="block w-full px-3 py-2 placeholder-gray-400 border rounded-sm shadow-sm appearance-none border-neutral-300 focus:outline-none focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm"
/>
</div>
</div> </div>
<PasswordField
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onInput={(e) => setPassword(e.currentTarget.value)}
/>
</div>
{secondFactorRequired && ( {secondFactorRequired && (
<div> <TextField
<label htmlFor="email" className="block text-sm font-medium text-neutral-700"> className="mt-1"
{t("2fa_code")} id="totpCode"
</label> name={t("2fa_code")}
<div className="mt-1"> type="text"
<input maxLength={6}
id="totpCode" minLength={6}
name="totpCode" inputMode="numeric"
type="text" value={code}
maxLength={6} onInput={(e) => setCode(e.currentTarget.value)}
minLength={6} />
inputMode="numeric"
value={code}
onInput={(e) => setCode(e.currentTarget.value)}
className="block w-full px-3 py-2 placeholder-gray-400 border rounded-sm shadow-sm appearance-none border-neutral-300 focus:outline-none focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm"
/>
</div>
</div>
)}
<div className="space-y-2">
<button
type="submit"
disabled={isSubmitting}
className="flex justify-center w-full px-4 py-2 text-sm font-medium text-white border border-transparent rounded-sm shadow-sm bg-neutral-900 hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
{t("sign_in")}
</button>
</div>
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
</form>
{isGoogleLoginEnabled && (
<div style={{ marginTop: "12px" }}>
<button
data-testid={"google"}
onClick={async () => await signIn("google")}
className="flex justify-center w-full px-4 py-2 text-sm font-medium text-black border border-transparent rounded-sm shadow-sm bg-secondary-50 hover:bg-secondary-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
{t("signin_with_google")}
</button>
</div>
)} )}
{isSAMLLoginEnabled && (
<div style={{ marginTop: "12px" }}>
<button
data-testid={"saml"}
onClick={async (event) => {
event.preventDefault();
if (!hostedCal) { <div className="flex space-y-2">
await signIn("saml", {}, { tenant: samlTenantID, product: samlProductID }); <Button className="flex justify-center w-full" type="submit" disabled={isSubmitting}>
} else { {t("sign_in")}
if (email.length === 0) { </Button>
setErrorMessage(t("saml_email_required")); </div>
return;
}
// hosted solution, fetch tenant and product from the backend {errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
mutation.mutate({ </form>
email, {isGoogleLoginEnabled && (
}); <div style={{ marginTop: "12px" }}>
<Button
color="secondary"
className="flex justify-center w-full"
data-testid={"google"}
onClick={async () => await signIn("google")}>
{" "}
{t("signin_with_google")}
</Button>
</div>
)}
{isSAMLLoginEnabled && (
<div style={{ marginTop: "12px" }}>
<Button
color="secondary"
data-testid={"saml"}
className="flex justify-center w-full"
onClick={async (event) => {
event.preventDefault();
if (!hostedCal) {
await signIn("saml", {}, { tenant: samlTenantID, product: samlProductID });
} else {
if (email.length === 0) {
setErrorMessage(t("saml_email_required"));
return;
} }
}}
className="flex justify-center w-full px-4 py-2 text-sm font-medium text-black border border-transparent rounded-sm shadow-sm bg-secondary-50 hover:bg-secondary-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black"> // hosted solution, fetch tenant and product from the backend
{t("signin_with_saml")} mutation.mutate({
</button> email,
</div> });
)} }
</div> }}>
<div className="mt-4 text-sm text-center text-neutral-600"> {t("signin_with_saml")}
{t("dont_have_an_account")} {/* replace this with your account creation flow */} </Button>
<a href={`${WEBSITE_URL}/signup`} className="font-medium text-neutral-900"> </div>
{t("create_an_account")} )}
</a> </AuthContainer>
</div>
</div>
<AddToHomescreen /> <AddToHomescreen />
</div> </>
); );
} }

View file

@ -7,7 +7,8 @@ import { useEffect } from "react";
import { useLocale } from "@lib/hooks/useLocale"; import { useLocale } from "@lib/hooks/useLocale";
import { inferSSRProps } from "@lib/types/inferSSRProps"; import { inferSSRProps } from "@lib/types/inferSSRProps";
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";
@ -23,40 +24,24 @@ export default function Logout(props: Props) {
const { t } = useLocale(); const { t } = useLocale();
return ( return (
<div <AuthContainer title={t("logged_out")} description={t("youve_been_logged_out")}>
className="fixed inset-0 z-50 overflow-y-auto" <div className="mb-4">
aria-labelledby="modal-title" <div className="flex items-center justify-center w-12 h-12 mx-auto bg-green-100 rounded-full">
role="dialog" <CheckIcon className="w-6 h-6 text-green-600" />
aria-modal="true"> </div>
<HeadSeo title={t("logged_out")} description={t("logged_out")} /> <div className="mt-3 text-center sm:mt-5">
<div className="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0"> <h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true"> {t("youve_been_logged_out")}
&#8203; </h3>
</span> <div className="mt-2">
<div className="inline-block px-4 pt-5 pb-4 overflow-hidden text-left align-bottom transition-all transform bg-white rounded-lg shadow-xl sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6"> <p className="text-sm text-gray-500">{t("hope_to_see_you_soon")}</p>
<div>
<div className="flex items-center justify-center w-12 h-12 mx-auto bg-green-100 rounded-full">
<CheckIcon className="w-6 h-6 text-green-600" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
{t("youve_been_logged_out")}
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500">{t("hope_to_see_you_soon")}</p>
</div>
</div>
</div>
<div className="mt-5 sm:mt-6">
<Link href="/auth/login">
<a className="inline-flex justify-center w-full px-4 py-2 text-base font-medium border border-transparent rounded-md shadow-sm bg-brand text-brandcontrast focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black sm:text-sm">
{t("go_back_login")}
</a>
</Link>
</div> </div>
</div> </div>
</div> </div>
</div> <Link href="/auth/login">
<Button className="flex justify-center w-full"> {t("go_back_login")}</Button>
</Link>
</AuthContainer>
); );
} }

View file

@ -158,6 +158,7 @@
"30min_meeting": "30 Min Meeting", "30min_meeting": "30 Min Meeting",
"secret_meeting": "Secret Meeting", "secret_meeting": "Secret Meeting",
"login_instead": "Login instead", "login_instead": "Login instead",
"already_have_an_account": "Already have an account?",
"create_account": "Create Account", "create_account": "Create Account",
"confirm_password": "Confirm password", "confirm_password": "Confirm password",
"create_your_account": "Create your account", "create_your_account": "Create your account",