Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix: warning if fast login unavailable #32

Merged
merged 2 commits into from
Dec 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ import { authService } from '~/shared/api/auth.service'
type Props = {
onError?: (err: unknown) => void
onSuccess?: (data: unknown) => void

loginIfNoCredentials?: (email: string) => void
}

export const useAuthenticateViaPasskeys = (props?: Props) => {
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
"email": {
"emailCannotBeEmpty": "Please enter your email address.",
"invalidEmail": "Please enter a valid email address."
}
},
"letsGetStarted": "Let’s get started"
}
}
3 changes: 2 additions & 1 deletion src/i18n/ru/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
"email": {
"emailCannotBeEmpty": "Пожалуйста, введите адрес электронной почты.",
"invalidEmail": "Пожалуйста, введите корректный адрес электронной почты."
}
},
"letsGetStarted": "Давайте начнем"
}
}
10 changes: 10 additions & 0 deletions src/widget/Login/lib/schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import z from 'zod'

export const emailSchema = z
.string()
.min(1, 'email.emailCannotBeEmpty')
.email('email.invalidEmail')

export const codeSchema = z
.string()
.regex(/^\d{6}$/, { message: 'code.shouldContainSixDigits' })
16 changes: 16 additions & 0 deletions src/widget/Login/ui/FastLoginUnavailableWarning.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Typography } from '~/shared/ui'

export const FastLoginUnavailableWarning = () => {
return (
<div className="w-[315px] border-1 border-black bg-white rounded-20 py-12 px-24 text-center">
<Typography
text="Oops.. Fast login unavailable"
classNames="font-black text-M text-red mb-16"
/>
<Typography
text="Set up Fast Login in your account settings after logging in with a code."
classNames="font-medium text-M text-black"
/>
</div>
)
}
5 changes: 4 additions & 1 deletion src/widget/Login/ui/LoginButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ export const LoginButton = forwardRef(
isDisabled={isDisabled}
classNames={classNames}
>
<Typography text={t(labelKey)} />
<Typography
text={t(labelKey)}
classNames="font-bold text-S uppercase"
/>
</Button>
</div>
)
Expand Down
11 changes: 9 additions & 2 deletions src/widget/Login/ui/LoginHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,18 @@ export const LoginHeader = () => {
<div className="flex flex-1 justify-center items-center">
<div className="w-full px-[16px] flex flex-col items-center">
<div className="w-[250px] py-[14px] px-[24px]">
<img className="w-[250px]" src="/web-app-manifest-512x512.png" alt="" />
<img
className="w-[250px]"
src="/web-app-manifest-512x512.png"
alt=""
/>
</div>
<Typography classNames="font-bold text-XXL whitespace-pre-wrap text-center text-black">
<Typography classNames="font-regular text-XXL whitespace-pre-wrap text-center text-black leading-8 mb-24">
{t('welcomeToChallengeLogger')}
</Typography>
<Typography classNames="font-bold text-S whitespace-pre-wrap text-center text-black">
{t('letsGetStarted')}
</Typography>
</div>
</div>
)
Expand Down
31 changes: 31 additions & 0 deletions src/widget/Login/ui/LoginInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { HTMLInputTypeAttribute } from 'react'

type Props = {
type: HTMLInputTypeAttribute
name: string
id: string
placeholder: string
autoComplete: string
onChange: (value: string) => void
}

export const LoginInput = ({
type,
name,
id,
placeholder,
autoComplete,
onChange,
}: Props) => {
return (
<input
type={type}
name={name}
id={id}
placeholder={placeholder}
autoComplete={autoComplete}
className="bg-transparent focus:outline-none duration-300 h-[40px] placeholder-black/50 border-b-1 border-violet hover:border-violet20 focus:border-violet50 w-[300px]"
onChange={(e) => onChange(e.target.value)}
/>
)
}
158 changes: 70 additions & 88 deletions src/widget/Login/ui/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import {
startAuthentication,
} from '@simplewebauthn/browser'
import { useMutation } from '@tanstack/react-query'
import { AxiosError } from 'axios'
import { useNavigate } from 'react-router-dom'
import { CSSTransition } from 'react-transition-group'
import z from 'zod'

import { LoginButton } from './LoginButton'
import { LoginHeader } from './LoginHeader'
Expand All @@ -19,60 +19,44 @@ import { authService } from '~/shared/api/auth.service'
import { Routes } from '~/shared/constants'
import { useToast } from '~/shared/hooks'
import { Typography } from '~/shared/ui'

const emailSchema = z
.string()
.min(1, 'email.emailCannotBeEmpty')
.email('email.invalidEmail')

const codeSchema = z
.string()
.regex(/^\d{6}$/, { message: 'code.shouldContainSixDigits' })
import { codeSchema, emailSchema } from '~/widget/Login/lib/schemas.ts'
import { FastLoginUnavailableWarning } from '~/widget/Login/ui/FastLoginUnavailableWarning.tsx'
import { LoginInput } from '~/widget/Login/ui/LoginInput.tsx'

export const LoginWidget = () => {
const { t } = useCustomTranslation()

const navigate = useNavigate()

const hintRef = useRef(null)
const fastLoginUnavailableWarningRef = useRef(null)

const {
showPromiseToast,
dismissAllToasts,
showErrorToast,
showInfoToast,
showWarningToast,
} = useToast()

const codeInputHeight = new Map([
[true, '40px'],
[false, '0px'],
])
const { showErrorToast, showInfoToast, showWarningToast, showSuccessToast } =
useToast()

const [email, setEmail] = useState<string>('')
const [code, setCode] = useState<string>('')

const { tryLoginPromise, confirmLoginPromise, loadingState, mutationState } =
useOTPLogin({
onCodeSuccess: () => {
setTimeout(() => {
dismissAllToasts()
return navigate(Routes.HOME)
}, 700)
},
onCodeError(error) {
if (error instanceof Error) {
return showErrorToast(error.message)
}
return showErrorToast(t('oopsSomethingWentWrong'))
},
onLoginError(error) {
if (error instanceof Error) {
return showErrorToast(error.message)
}
return showErrorToast(t('oopsSomethingWentWrong'))
},
})
const { tryLogin, confirmLogin, loadingState, mutationState } = useOTPLogin({
onLoginSuccess: () => {
return showSuccessToast(t('emailSent'))
},
onCodeSuccess: () => {
return navigate(Routes.HOME)
},
onCodeError(error) {
if (error instanceof Error) {
return showErrorToast(error.message)
}
return showErrorToast(t('oopsSomethingWentWrong'))
},
onLoginError(error) {
if (error instanceof Error) {
return showErrorToast(error.message)
}
return showErrorToast(t('oopsSomethingWentWrong'))
},
})

const verifyLoginChallenge = useMutation({
mutationFn: authService.verifyAuthentication,
Expand All @@ -81,13 +65,12 @@ export const LoginWidget = () => {
},
})

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

const isFastLoginUnavailable =
passkeysMutation.error instanceof AxiosError
? passkeysMutation.error.status === 400
: false

const isEmailSent =
mutationState.loginMutation.isSuccess && !!mutationState.loginMutation.data
Expand All @@ -108,17 +91,13 @@ export const LoginWidget = () => {
}

return safeParse.data
}, [email, showWarningToast])
}, [email, showWarningToast, t])

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

return showPromiseToast(tryLoginPromise(safeEmail), {
pending: t('sendingEmail'),
success: t('emailSent'),
error: t('failedToSendEmail'),
})
}, [processEmailValue, showPromiseToast, t, tryLoginPromise])
return tryLogin(safeEmail)
}, [processEmailValue, tryLogin])

const confirmLoginOTP = useCallback(() => {
if (!isEmailSent) {
Expand All @@ -133,23 +112,8 @@ export const LoginWidget = () => {
return showWarningToast(t(codeSafeParse.error.errors[0].message))
}

return showPromiseToast(
confirmLoginPromise(emailValue, codeSafeParse.data),
{
pending: t('sendingCode'),
success: t('codeSent'),
error: t('failedToSendCode'),
},
)
}, [
code,
confirmLoginPromise,
isEmailSent,
processEmailValue,
showPromiseToast,
showWarningToast,
t,
])
return confirmLogin(emailValue, codeSafeParse.data)
}, [code, confirmLogin, isEmailSent, processEmailValue, showWarningToast, t])

const loginPasskeys = useCallback(async () => {
const emailValue = processEmailValue()
Expand All @@ -176,38 +140,56 @@ export const LoginWidget = () => {
if (verifyResult.data.success) {
return navigate(Routes.HOME)
}
}, [navigate, passkeysMutation, processEmailValue, verifyLoginChallenge])
}, [
navigate,
passkeysMutation,
processEmailValue,
showInfoToast,
t,
verifyLoginChallenge,
])

return (
<div className="flex flex-1 flex-col items-center">
<div className="flex flex-col items-center gap-S mb-64">
<LoginHeader />
<input

<CSSTransition
in={isFastLoginUnavailable && !isEmailSent}
nodeRef={fastLoginUnavailableWarningRef}
timeout={1000}
classNames="node-opacity"
unmountOnExit
>
<div ref={fastLoginUnavailableWarningRef}>
<FastLoginUnavailableWarning />
</div>
</CSSTransition>

<LoginInput
type="email"
name="email"
id="input-email"
placeholder="email"
autoComplete="email"
className="bg-transparent focus:outline-none duration-300 h-[40px] placeholder-black/50 border-b-2 border-black hover:border-black/20 focus:border-black/50 w-[300px]"
onChange={(e) => setEmail(e.target.value)}
onChange={(value) => setEmail(value)}
/>

<div
className="overflow-hidden duration-300"
style={{
height: `${codeInputHeight.get(codeInputShouldBeShown)}`,
}}
className={`overflow-hidden duration-300 ${codeInputShouldBeShown ? 'h-10' : 'h-0'}`}
>
<input
<LoginInput
type="text"
name="code"
id="input-code"
placeholder="code"
className={`bg-transparent focus:outline-none duration-300 h-[40px] placeholder-black/50 border-b-2 border-black hover:border-black/20 focus:border-black/50 w-[300px]`}
onChange={(e) => setCode(e.target.value)}
autoComplete="code"
onChange={(value) => setCode(value)}
/>
</div>

<LoginButton
labelKey={'loginWithCode'}
labelKey={codeInputShouldBeShown ? 'login' : 'loginWithCode'}
onClick={codeInputShouldBeShown ? confirmLoginOTP : loginOTP}
isDisabled={
passkeysMutation.isPending || verifyLoginChallenge.isPending
Expand All @@ -218,7 +200,7 @@ export const LoginWidget = () => {
classNames="bg-violet20 border-violet"
/>

{!isEmailSent && browserSupportsWebAuthn() && (
{!codeInputShouldBeShown && browserSupportsWebAuthn() && (
<LoginButton
labelKey={'fastLogin'}
onClick={loginPasskeys}
Expand All @@ -229,7 +211,7 @@ export const LoginWidget = () => {
isLoading={
passkeysMutation.isPending || verifyLoginChallenge.isPending
}
classNames="border-violet"
classNames="border-violet bg-white"
/>
)}
</div>
Expand Down
6 changes: 6 additions & 0 deletions tailwind.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ export default {
64: '64px',
96: '96px',
},
borderWidth: {
1: '1px',
},
borderRadius: {
20: '20px',
},
keyframes: {
shakes: {
'0%': {
Expand Down
Loading