Skip to content

Commit

Permalink
feat: pwdless sign in flow (#3960)
Browse files Browse the repository at this point in the history
  • Loading branch information
emilyjablonski committed Mar 19, 2024
1 parent 5c96b01 commit ca4c843
Show file tree
Hide file tree
Showing 11 changed files with 178 additions and 55 deletions.
5 changes: 4 additions & 1 deletion api/prisma/seed-dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion api/src/views/partials/user-name.hbs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{{user.firstName}}
{{#if user.middleName}}
{{user.middleName}}
{{/if}} {{user.lastName}}
{{/if}} {{user.lastName}}
1 change: 0 additions & 1 deletion api/src/views/single-use-code.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
<p>
{{t "singleUseCodeEmail.message" singleUseCodeOptions }}
</p>
<br />
<p>
{{t "singleUseCodeEmail.singleUseCode" singleUseCodeOptions}}
</p>
Expand Down
12 changes: 12 additions & 0 deletions shared-helpers/src/auth/AuthContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
UserCreate,
UserService,
serviceOptions,
SuccessDTO,
} from "../types/backend-swagger"
import { getListingRedirectUrl } from "../utilities/getListingRedirectUrl"

Expand Down Expand Up @@ -74,6 +75,7 @@ type ContextProps = {
mfaType: MfaType,
phoneNumber?: string
) => Promise<RequestMfaCodeResponse | undefined>
requestSingleUseCode: (email: string) => Promise<SuccessDTO | undefined>
loginViaSingleUseCode: (email: string, singleUseCode: string) => Promise<User | undefined>
}

Expand Down Expand Up @@ -360,6 +362,16 @@ export const AuthProvider: FunctionComponent<React.PropsWithChildren> = ({ 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)
}
9 changes: 9 additions & 0 deletions shared-helpers/src/auth/catchNetworkError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export type NetworkErrorReset = () => void
export enum NetworkErrorMessage {
PasswordOutdated = "but password is no longer valid",
MfaUnauthorized = "mfaUnauthorized",
SingleUseCodeUnauthorized = "singleUseCodeUnauthorized",
}

/**
Expand All @@ -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"),
Expand Down
8 changes: 6 additions & 2 deletions shared-helpers/src/locales/general.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
12 changes: 7 additions & 5 deletions shared-helpers/src/views/sign-in/FormSignInPwdless.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -9,6 +9,8 @@ import styles from "./FormSignIn.module.scss"
export type FormSignInPwdlessProps = {
control: FormSignInPwdlessControl
onSubmit: (data: FormSignInPwdlessValues) => void
useCode: boolean
setUseCode: React.Dispatch<React.SetStateAction<boolean>>
}

export type FormSignInPwdlessValues = {
Expand All @@ -25,6 +27,8 @@ export type FormSignInPwdlessControl = {
const FormSignInPwdless = ({
onSubmit,
control: { errors, register, handleSubmit },
useCode,
setUseCode,
}: FormSignInPwdlessProps) => {
const onError = () => {
window.scrollTo(0, 0)
Expand All @@ -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 (
<Form id="sign-in" onSubmit={handleSubmit(onSubmit, onError)}>
<Field
className={styles["sign-in-email-input"]}
name="email"
label={t("t.email")}
labelClassName={`text__caps-spaced ${useCode && "pb-0"}`}
labelClassName={`text__caps-spaced`}
validation={{ required: true }}
error={errors.email}
errorMessage={t("authentication.signIn.enterLoginEmail")}
Expand All @@ -63,7 +65,7 @@ const FormSignInPwdless = ({
name="password"
label={t("authentication.createAccount.password")}
labelClassName="text__caps-spaced"
validation={{ required: !useCode }}
validation={{ required: useCode === false }}
error={errors.password}
errorMessage={t("authentication.signIn.enterLoginPassword")}
register={register}
Expand Down
44 changes: 41 additions & 3 deletions sites/public/src/pages/sign-in.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useContext, useEffect, useRef, useState, useCallback } from "react"
import { useForm } from "react-hook-form"
import { useRouter } from "next/router"
import { t, setSiteAlertMessage, useMutate } from "@bloom-housing/ui-components"
import FormsLayout from "../layouts/forms"
import { useRedirectToPrevPage } from "../lib/hooks"
Expand All @@ -22,13 +23,15 @@ import signUpBenefitsStyles from "../../styles/sign-up-benefits.module.scss"
import SignUpBenefitsHeadingGroup from "../components/account/SignUpBenefitsHeadingGroup"

const SignIn = () => {
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()

Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -154,8 +190,10 @@ const SignIn = () => {
>
{process.env.showPwdless ? (
<FormSignInPwdless
onSubmit={(data) => void onSubmit(data)}
onSubmit={(data) => void onSubmitPwdless(data)}
control={{ register, errors, handleSubmit }}
useCode={useCode}
setUseCode={setUseCode}
/>
) : (
<FormSignInDefault
Expand Down
Loading

0 comments on commit ca4c843

Please sign in to comment.