Skip to content

Commit

Permalink
Feature: Splitting login flow (#17)
Browse files Browse the repository at this point in the history
* Split login flow on two different flows

* Refactor: Re-organize the LoginWidget
  • Loading branch information
moiskillnadne authored Nov 2, 2024
1 parent fdba3a4 commit 29fea52
Show file tree
Hide file tree
Showing 7 changed files with 123 additions and 59 deletions.
1 change: 1 addition & 0 deletions src/feature/LoginOTP/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './lib'
1 change: 1 addition & 0 deletions src/feature/LoginOTP/lib/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './useOTPLogin'
72 changes: 72 additions & 0 deletions src/feature/LoginOTP/lib/useOTPLogin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { useCallback } from 'react'

import { useMutation } from '@tanstack/react-query'

import { authService } from '~/shared/api/auth.service'

type Props = {
onLoginSuccess?: () => void
onCodeSuccess?: () => void
}

export const useOTPLogin = (props?: Props) => {
const loginMutation = useMutation({
mutationFn: authService.login,
onSuccess: () => {
console.info('[LoginMutation:onSuccess]')

if (props?.onLoginSuccess) {
props.onLoginSuccess()
}
},
onError: (err) => {
console.error(`[LoginMutation:onError] ${JSON.stringify(err)}`)
},
})

const codeMutation = useMutation({
mutationFn: authService.confirmLogin,
onSuccess: () => {
console.info('[CodeMutation:onSuccess]')

if (props?.onCodeSuccess) {
props.onCodeSuccess()
}
},
onError: (err) => {
console.info(`[CodeMutation:onError]: ${JSON.stringify(err)}`)
},
})

const tryLogin = useCallback(
(email: string) => {
loginMutation.mutate({ email })
},
[loginMutation],
)

const confirmLogin = useCallback(
(email: string, code: string) => {
codeMutation.mutate({ email, code })
},
[codeMutation],
)

return {
tryLogin,
confirmLogin,
loadingState: {
isLoading: loginMutation.isPending || codeMutation.isPending,
isTryLoginLoading: loginMutation.isPending,
isConfirmLoginLoading: codeMutation.isPending,
},
errorState: {
tryLoginError: loginMutation.error,
confirmLoginError: codeMutation.error,
},
mutationState: {
loginMutation,
codeMutation,
},
}
}
1 change: 1 addition & 0 deletions src/i18n/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"toCompleteChallenge": "to complete your daily challenge!",
"youCanRestFor": "you can rest for",
"login": "Login",
"fastLogin": "Fast login",
"logout": "Logout",
"enterEmailForCode": "Enter your email. You will receive an email with an authorization code.",
"sendCode": "Send code",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/ru/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"toCompleteChallenge": "для выполнения вашего ежедневного вызова!",
"youCanRestFor": "вы можете отдыхать еще",
"login": "Логин",
"fastLogin": "Быстрый вход",
"logout": "Выйти",
"enterEmailForCode": "Введите ваш емайл. Вам придет письмо с кодом для авторизации.",
"sendCode": "Отправить код",
Expand Down
4 changes: 3 additions & 1 deletion src/widget/Login/ui/LoginButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@ type Props = {
labelKey: string
onClick: () => void
isLoading: boolean
isDisabled?: boolean
}

export const LoginButton = ({ onClick, isLoading, labelKey }: Props) => {
export const LoginButton = ({ onClick, isLoading, labelKey, isDisabled }: Props) => {
const { t } = useCustomTranslation()

return (
<div className="w-[300px] h-[45px]">
<button
onClick={onClick}
disabled={isDisabled || isLoading}
className="border-[1px] rounded-full bg-green border-black h-full w-full flex justify-center items-center"
>
{isLoading ? (
Expand Down
102 changes: 44 additions & 58 deletions src/widget/Login/ui/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { LoginButton } from './LoginButton'
import { LoginHeader } from './LoginHeader'

import { useAuthenticateViaPasskeys } from '~/feature/AuthorizePasskeys/'
import { useOTPLogin } from '~/feature/LoginOTP'
import { useCustomTranslation } from '~/feature/translation'
import { authService } from '~/shared/api/auth.service'
import { Routes } from '~/shared/constants'
Expand All @@ -29,15 +30,10 @@ export const LoginWidget = () => {

const [email, setEmail] = useState<string>('')
const [code, setCode] = useState<string>('')
const [loginFlow, setLoginFlow] = useState<'otp' | 'passkeys' | null>(null)

const loginMutation = useMutation({
mutationFn: authService.login,
onSuccess: (data) => {
console.info('[LoginMutation:onSuccess]', data)
},
onError: (err) => {
console.error(`[LoginMutation:onError] ${JSON.stringify(err)}`)
const { tryLogin, confirmLogin, loadingState, mutationState } = useOTPLogin({
onCodeSuccess: () => {
return navigate(Routes.HOME)
},
})

Expand All @@ -48,86 +44,71 @@ export const LoginWidget = () => {
},
})

const codeMutation = useMutation({
mutationFn: authService.confirmLogin,
onSuccess: (data) => {
console.info('[CodeMutation:onSuccess]', data)

return navigate(Routes.HOME)
},
onError: (err) => {
console.info(`[CodeMutation:onError]: ${JSON.stringify(err)}`)
},
})

const passkeysMutation = useAuthenticateViaPasskeys({
loginIfNoCredentials: (email: string) => {
console.info(`[LoginWidget:passkeysMutation] No credentials for: ${email}`)
},
})

const isEmailSent = loginMutation.isSuccess && !!loginMutation.data
const isEmailSent = mutationState.loginMutation.isSuccess && !!mutationState.loginMutation.data

const confirmLogin = () => {
if (!isEmailSent) {
throw new Error('Email should be sent first')
const processEmailValue = useCallback(() => {
const safeParse = emailSchema.safeParse(email)

if (safeParse.error) {
throw new Error(JSON.stringify(safeParse.error))
}

const emailSafeParse = emailSchema.safeParse(email)
return safeParse.data
}, [email])

const loginOTP = useCallback(async () => {
const emailValue = processEmailValue()

if (emailSafeParse.error) {
throw new Error(JSON.stringify(emailSafeParse.error))
tryLogin(emailValue)
}, [tryLogin, processEmailValue])

const confirmLoginOTP = useCallback(() => {
if (!isEmailSent) {
throw new Error('Email should be sent first')
}

const emailValue = processEmailValue()

const codeSafeParse = codeSchema.safeParse(code)

if (codeSafeParse.error) {
throw new Error(JSON.stringify(codeSafeParse.error))
}

codeMutation.mutate({ email, code })
}
confirmLogin(emailValue, codeSafeParse.data)
}, [code, confirmLogin, isEmailSent, processEmailValue])

const handleLogin = useCallback(async () => {
const safeParse = emailSchema.safeParse(email)
const loginPasskeys = useCallback(async () => {
const emailValue = processEmailValue()

if (safeParse.error) {
throw new Error(JSON.stringify(safeParse.error))
}

if (!browserSupportsWebAuthn()) {
setLoginFlow('otp')
return loginMutation.mutate({
email: safeParse.data,
})
}

const response = await passkeysMutation.mutateAsync(safeParse.data)
const response = await passkeysMutation.mutateAsync(emailValue)

const challengeOpts = response.data.options

const isCredentialExist =
challengeOpts.allowCredentials && challengeOpts.allowCredentials.length > 0

if (!isCredentialExist) {
setLoginFlow('otp')
return loginMutation.mutate({
email: safeParse.data,
})
throw new Error('No credentials found')
}

const result = await startAuthentication({ optionsJSON: challengeOpts })

setLoginFlow('passkeys')
const verifyResult = await verifyLoginChallenge.mutateAsync({
email: safeParse.data,
email: emailValue,
challengeResponse: result,
})

if (verifyResult.data.success) {
return navigate(Routes.HOME)
}
}, [email, passkeysMutation, verifyLoginChallenge, loginMutation, navigate])
}, [navigate, passkeysMutation, processEmailValue, verifyLoginChallenge])

return (
<div className="flex flex-1 flex-col items-center">
Expand All @@ -146,7 +127,7 @@ export const LoginWidget = () => {

<div
className="overflow-hidden duration-300"
style={{ height: `${codeInputVisibility.get(loginFlow === 'otp')}` }}
style={{ height: `${codeInputVisibility.get(mutationState.loginMutation.isSuccess)}` }}
>
<input
type="text"
Expand All @@ -160,14 +141,19 @@ export const LoginWidget = () => {

<LoginButton
labelKey={'login'}
onClick={isEmailSent ? confirmLogin : handleLogin}
isLoading={
passkeysMutation.isPending ||
loginMutation.isPending ||
verifyLoginChallenge.isPending ||
codeMutation.isPending
}
onClick={isEmailSent ? confirmLoginOTP : loginOTP}
isDisabled={passkeysMutation.isPending || verifyLoginChallenge.isPending}
isLoading={loadingState.isTryLoginLoading || loadingState.isConfirmLoginLoading}
/>

{browserSupportsWebAuthn() && (
<LoginButton
labelKey={'fastLogin'}
onClick={loginPasskeys}
isDisabled={loadingState.isTryLoginLoading || loadingState.isConfirmLoginLoading}
isLoading={passkeysMutation.isPending || verifyLoginChallenge.isPending}
/>
)}
</div>

<div className="w-[350px] px-[12px]">
Expand Down

0 comments on commit 29fea52

Please sign in to comment.