From ca4c8434ea9b9ee01627897d0ef193af1a037e2f Mon Sep 17 00:00:00 2001 From: Emily Jablonski <65367387+emilyjablonski@users.noreply.github.com> Date: Tue, 19 Mar 2024 12:40:42 -0600 Subject: [PATCH] feat: pwdless sign in flow (#3960) --- api/prisma/seed-dev.ts | 5 +- api/src/views/partials/user-name.hbs | 2 +- api/src/views/single-use-code.hbs | 1 - shared-helpers/src/auth/AuthContext.ts | 12 ++ shared-helpers/src/auth/catchNetworkError.ts | 9 ++ shared-helpers/src/locales/general.json | 8 +- .../src/views/sign-in/FormSignInPwdless.tsx | 12 +- sites/public/src/pages/sign-in.tsx | 44 ++++++- sites/public/src/pages/verify.tsx | 114 +++++++++++++----- sites/public/styles/overrides.scss | 3 + sites/public/styles/verify.module.scss | 23 ++-- 11 files changed, 178 insertions(+), 55 deletions(-) diff --git a/api/prisma/seed-dev.ts b/api/prisma/seed-dev.ts index 357118d71a..73139b5bb9 100644 --- a/api/prisma/seed-dev.ts +++ b/api/prisma/seed-dev.ts @@ -45,7 +45,10 @@ export const devSeeding = async ( jurisdictionName?: string, ) => { const jurisdiction = await prismaClient.jurisdictions.create({ - data: jurisdictionFactory(jurisdictionName), + data: { + ...jurisdictionFactory(jurisdictionName), + allowSingleUseCodeLogin: true, + }, }); await prismaClient.userAccounts.create({ data: await userFactory({ diff --git a/api/src/views/partials/user-name.hbs b/api/src/views/partials/user-name.hbs index 5f8a7f3e24..52e44c0cf3 100644 --- a/api/src/views/partials/user-name.hbs +++ b/api/src/views/partials/user-name.hbs @@ -1,4 +1,4 @@ {{user.firstName}} {{#if user.middleName}} {{user.middleName}} -{{/if}} {{user.lastName}} +{{/if}} {{user.lastName}} \ No newline at end of file diff --git a/api/src/views/single-use-code.hbs b/api/src/views/single-use-code.hbs index 857f09b468..026666eb37 100644 --- a/api/src/views/single-use-code.hbs +++ b/api/src/views/single-use-code.hbs @@ -2,7 +2,6 @@

{{t "singleUseCodeEmail.message" singleUseCodeOptions }}

-

{{t "singleUseCodeEmail.singleUseCode" singleUseCodeOptions}}

diff --git a/shared-helpers/src/auth/AuthContext.ts b/shared-helpers/src/auth/AuthContext.ts index 6bf148db37..d6e141d290 100644 --- a/shared-helpers/src/auth/AuthContext.ts +++ b/shared-helpers/src/auth/AuthContext.ts @@ -31,6 +31,7 @@ import { UserCreate, UserService, serviceOptions, + SuccessDTO, } from "../types/backend-swagger" import { getListingRedirectUrl } from "../utilities/getListingRedirectUrl" @@ -74,6 +75,7 @@ type ContextProps = { mfaType: MfaType, phoneNumber?: string ) => Promise + requestSingleUseCode: (email: string) => Promise loginViaSingleUseCode: (email: string, singleUseCode: string) => Promise } @@ -360,6 +362,16 @@ export const AuthProvider: FunctionComponent = ({ child dispatch(stopLoading()) } }, + requestSingleUseCode: async (email) => { + dispatch(startLoading()) + try { + return await authService?.requestSingleUseCode({ + body: { email }, + }) + } finally { + dispatch(stopLoading()) + } + }, } return createElement(AuthContext.Provider, { value: contextValues }, children) } diff --git a/shared-helpers/src/auth/catchNetworkError.ts b/shared-helpers/src/auth/catchNetworkError.ts index e61114a421..a3ee02e457 100644 --- a/shared-helpers/src/auth/catchNetworkError.ts +++ b/shared-helpers/src/auth/catchNetworkError.ts @@ -29,6 +29,7 @@ export type NetworkErrorReset = () => void export enum NetworkErrorMessage { PasswordOutdated = "but password is no longer valid", MfaUnauthorized = "mfaUnauthorized", + SingleUseCodeUnauthorized = "singleUseCodeUnauthorized", } /** @@ -54,6 +55,14 @@ export const useCatchNetworkError = () => { }), error, }) + } else if (message === NetworkErrorMessage.SingleUseCodeUnauthorized) { + setNetworkError({ + title: t("authentication.signIn.pwdless.error"), + description: t("authentication.signIn.afterFailedAttempts", { + count: error?.response?.data?.failureCountRemaining || 5, + }), + error, + }) } else { setNetworkError({ title: t("authentication.signIn.enterValidEmailAndPassword"), diff --git a/shared-helpers/src/locales/general.json b/shared-helpers/src/locales/general.json index 50872db5f6..c4b57ec052 100644 --- a/shared-helpers/src/locales/general.json +++ b/shared-helpers/src/locales/general.json @@ -40,14 +40,16 @@ "account.settings.update": "Update", "account.settings.iconTitle": "generic user", "account.pwdless.code": "Your code", - "account.pwdless.codeAlert": "We sent a code to %{email} to finish signing up. Be aware, the code will expire in 5 minutes.", + "account.pwdless.createMessage": "We sent a code to %{email} to finish signing up. Be aware, the code will expire in 5 minutes.", + "account.pwdless.loginMessage": "If there is an account made with %{email}, we’ll send a code within 5 minutes. If you don’t receive a code, sign in with your password and confirm your email address under account settings.", "account.pwdless.codeNewAlert": "A new code has been sent to %{email}. Be aware, the code will expire in 5 minutes.", "account.pwdless.continue": "Continue", "account.pwdless.notReceived": "Didn't receive your code?", "account.pwdless.resend": "Resend", "account.pwdless.resendCode": "Resend Code", "account.pwdless.resendCodeButton": "Resend the code", - "account.pwdless.resendCodeHelper": "If there is an account made with that email, we’ll send a new code. Be aware, the code will expire in 5 minutes.", + "account.pwdless.resendCodeHelper": "If there is an account made with %{email}, we’ll send a new code. Be aware, the code will expire in 5 minutes.", + "account.pwdless.signInWithYourPassword": "Sign in with your password", "account.pwdless.verifyTitle": "Verify that it's you", "alert.maintenance": "This site is undergoing scheduled maintenance. We apologize for any inconvenience.", "application.ada.hearing": "For Hearing Impairments", @@ -552,6 +554,7 @@ "authentication.signIn.passwordOutdated": "Your password has expired. Please reset your password.", "authentication.signIn.pwdless.createAccountCopy": "Sign up quicky with no need to remember any passwords.", "authentication.signIn.pwdless.emailHelperText": "Enter your email and we'll send you a code to sign in.", + "authentication.signIn.pwdless.error": "The code you've used is invalid or expired.", "authentication.signIn.pwdless.getCode": "Get code to sign in", "authentication.signIn.pwdless.useCode": "Get a code instead", "authentication.signIn.pwdless.usePassword": "Use your password instead", @@ -948,6 +951,7 @@ "t.lastUpdated": "Last Updated", "t.less": "Less", "t.letter": "Letter", + "t.loading": "Loading", "t.loginIsRequired": "Login is required to view this page.", "t.menu": "Menu", "t.minimumIncome": "Minimum Income", diff --git a/shared-helpers/src/views/sign-in/FormSignInPwdless.tsx b/shared-helpers/src/views/sign-in/FormSignInPwdless.tsx index cae31bf2c8..03acb314ae 100644 --- a/shared-helpers/src/views/sign-in/FormSignInPwdless.tsx +++ b/shared-helpers/src/views/sign-in/FormSignInPwdless.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useState } from "react" +import React, { useContext } from "react" import { useRouter } from "next/router" import type { UseFormMethods } from "react-hook-form" import { Field, Form, NavigationContext, t } from "@bloom-housing/ui-components" @@ -9,6 +9,8 @@ import styles from "./FormSignIn.module.scss" export type FormSignInPwdlessProps = { control: FormSignInPwdlessControl onSubmit: (data: FormSignInPwdlessValues) => void + useCode: boolean + setUseCode: React.Dispatch> } export type FormSignInPwdlessValues = { @@ -25,6 +27,8 @@ export type FormSignInPwdlessControl = { const FormSignInPwdless = ({ onSubmit, control: { errors, register, handleSubmit }, + useCode, + setUseCode, }: FormSignInPwdlessProps) => { const onError = () => { window.scrollTo(0, 0) @@ -34,15 +38,13 @@ const FormSignInPwdless = ({ const listingIdRedirect = router.query?.listingId as string const forgetPasswordURL = getListingRedirectUrl(listingIdRedirect, "/forgot-password") - const [useCode, setUseCode] = useState(true) - return (
{ - const { login, userService } = useContext(AuthContext) + const router = useRouter() + + const { login, requestSingleUseCode, userService } = useContext(AuthContext) const signUpCopy = process.env.showMandatedAccounts /* Form Handler */ // This is causing a linting issue with unbound-method, see open issue as of 10/21/2020: // https://github.com/react-hook-form/react-hook-form/issues/2887 // eslint-disable-next-line @typescript-eslint/unbound-method - const { register, handleSubmit, errors, watch, reset } = useForm() + const { register, handleSubmit, errors, watch, reset, clearErrors } = useForm() const redirectToPage = useRedirectToPrevPage("/account/dashboard") const { networkError, determineNetworkError, resetNetworkError } = useCatchNetworkError() @@ -41,6 +44,11 @@ const SignIn = () => { type: NetworkStatusType }>() + type LoginType = "pwd" | "code" + const loginType = router.query?.loginType as LoginType + + const [useCode, setUseCode] = useState(loginType !== "pwd") + const { mutate: mutateResendConfirmation, reset: resetResendConfirmation, @@ -68,6 +76,34 @@ const SignIn = () => { } } + const onSubmitPwdless = async (data: { email: string; password: string }) => { + const { email, password } = data + + try { + if (useCode) { + clearErrors() + await requestSingleUseCode(email) + const redirectUrl = router.query?.redirectUrl as string + const listingId = router.query?.listingId as string + let queryParams: { [key: string]: string } = { email, flowType: "login" } + if (redirectUrl) queryParams = { ...queryParams, redirectUrl } + if (listingId) queryParams = { ...queryParams, listingId } + + await router.push({ + pathname: "/verify", + query: queryParams, + }) + } else { + const user = await login(email, password) + setSiteAlertMessage(t(`authentication.signIn.success`, { name: user.firstName }), "success") + await redirectToPage() + } + } catch (error) { + const { status } = error.response || {} + determineNetworkError(status, error) + } + } + const onResendConfirmationSubmit = useCallback( (email: string) => { void mutateResendConfirmation( @@ -154,8 +190,10 @@ const SignIn = () => { > {process.env.showPwdless ? ( void onSubmit(data)} + onSubmit={(data) => void onSubmitPwdless(data)} control={{ register, errors, handleSubmit }} + useCode={useCode} + setUseCode={setUseCode} /> ) : ( { + const router = useRouter() + // eslint-disable-next-line @typescript-eslint/unbound-method - const { register, handleSubmit, errors } = useForm() - const { determineNetworkError } = useCatchNetworkError() + const { register, handleSubmit, errors, reset } = useForm() + const { networkError, determineNetworkError, resetNetworkError } = useCatchNetworkError() + const { requestSingleUseCode, loginViaSingleUseCode } = useContext(AuthContext) + const redirectToPage = useRedirectToPrevPage("/account/dashboard") + + type FlowType = "create" | "login" + const email = router.query?.email as string + const flowType = router.query?.flowType as FlowType const [isModalOpen, setIsModalOpen] = useState(false) - const [alertMessage, setAlertMessage] = useState( - t("account.pwdless.codeAlert", { email: "example@email.com" }) - ) // This copy will change based on coming from the sign in flow or create account flow + const [isResendLoading, setIsResendLoading] = useState(false) + const [isLoginLoading, setIsLoginLoading] = useState(false) + const alertMessage = + flowType === "create" + ? t("account.pwdless.createMessage", { email }) + : t("account.pwdless.loginMessage", { email }) useEffect(() => { pushGtmEvent({ @@ -36,15 +51,19 @@ const Verify = () => { }) }, []) - const onSubmit = (data: { code: string }) => { - // const { code } = data + const onSubmit = async (data: { code: string }) => { + const { code } = data try { - // Attempt to either create an account or sign in + setIsLoginLoading(true) + const user = await loginViaSingleUseCode(email, code) + setIsLoginLoading(false) + setSiteAlertMessage(t(`authentication.signIn.success`, { name: user.firstName }), "success") + await redirectToPage() } catch (error) { + setIsLoginLoading(false) const { status } = error.response || {} determineNetworkError(status, error) - // "The code you've used is invalid or expired" } } @@ -53,20 +72,27 @@ const Verify = () => { <> - + { + reset() + resetNetworkError() + }, + }} + errorMessageId={"verify-sign-in"} + className={styles["verify-error-container"]} + /> {!!Object.keys(errors).length && ( - + {t("errors.errorsToResolve")} )} - {alertMessage} + {alertMessage && {alertMessage}} { + {flowType === "login" && ( + + {" "} + {t("t.or")}{" "} + + + )} - @@ -95,19 +140,34 @@ const Verify = () => { setIsModalOpen(false)}> {t("account.pwdless.resendCode")} - {t("account.pwdless.resendCodeHelper")} + {t("account.pwdless.resendCodeHelper", { email })} - diff --git a/sites/public/styles/overrides.scss b/sites/public/styles/overrides.scss index d4664d0f81..ab11e79338 100644 --- a/sites/public/styles/overrides.scss +++ b/sites/public/styles/overrides.scss @@ -9,4 +9,7 @@ text-transform: none; color: var(--seeds-input-text-label-color); } + .label.text__caps-spaced { + margin-bottom: var(--seeds-s1); + } } diff --git a/sites/public/styles/verify.module.scss b/sites/public/styles/verify.module.scss index 791692ac81..ccadaeb89b 100644 --- a/sites/public/styles/verify.module.scss +++ b/sites/public/styles/verify.module.scss @@ -1,10 +1,12 @@ .verify-resend-link { margin-bottom: var(--seeds-s6); + font-size: var(--seeds-type-caption-size); + color: var(--seeds-text-color); } .verify-message { max-width: 100%; - margin-bottom: var(--seeds-s4); + margin-bottom: var(--seeds-s8); } .verify-error { @@ -15,19 +17,10 @@ margin-bottom: 0; } -.terms-card { - overflow-y: auto; - max-height: calc(100vh - 24rem); - min-height: 30rem; - position: relative; - margin-bottom: var(--seeds-s12); - @media (max-width: theme("screens.sm")) { - height: 100%; - max-height: 100%; - margin-bottom: 0; - } -} +.verify-error-container { + margin-inline: var(--seeds-s4); -.terms-form { - margin-bottom: var(--seeds-12); + @media (min-width: theme("screens.sm")) { + margin-inline: var(--seeds-s12); + } }