Skip to content

Commit

Permalink
[Seedless Onboarding]: Add password recovery modal design (#2653)
Browse files Browse the repository at this point in the history
* feat: Add password recovery modal design

* fix: Close modal on login

* fix: Extend disabled state, add progress bar

* fix: Close connection popover when password recovery opens

* fix: Move logic from useEffect into login handler

* refactor: Reuse PasswordInput
  • Loading branch information
usame-algan authored Oct 23, 2023
1 parent 0140ec0 commit 8500cdf
Show file tree
Hide file tree
Showing 8 changed files with 136 additions and 76 deletions.
40 changes: 26 additions & 14 deletions src/components/common/ConnectWallet/MPCLogin.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { MPCWalletState } from '@/hooks/wallets/mpc/useMPCWallet'
import { Box, Button, SvgIcon, Typography } from '@mui/material'
import { useContext, useMemo } from 'react'
import { useCallback, useContext, useMemo } from 'react'
import { MpcWalletContext } from './MPCWalletProvider'
import { PasswordRecovery } from './PasswordRecovery'
import GoogleLogo from '@/public/images/welcome/logo-google.svg'
Expand All @@ -16,6 +16,8 @@ import { isSocialWalletEnabled } from '@/hooks/wallets/wallets'
import { isSocialLoginWallet } from '@/services/mpc/module'
import { CGW_NAMES } from '@/hooks/wallets/consts'
import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk'
import { TxModalContext } from '@/components/tx-flow'
import { COREKIT_STATUS } from '@web3auth/mpc-core-kit'

export const _getSupportedChains = (chains: ChainInfo[]) => {
return chains
Expand All @@ -37,7 +39,9 @@ const useIsSocialWalletEnabled = () => {
}

const MPCLogin = ({ onLogin }: { onLogin?: () => void }) => {
const { triggerLogin, userInfo, walletState, recoverFactorWithPassword } = useContext(MpcWalletContext)
const { triggerLogin, userInfo, walletState, setWalletState, recoverFactorWithPassword } =
useContext(MpcWalletContext)
const { setTxFlow } = useContext(TxModalContext)

const wallet = useWallet()
const loginPending = walletState === MPCWalletState.AUTHENTICATING
Expand All @@ -48,21 +52,33 @@ const MPCLogin = ({ onLogin }: { onLogin?: () => void }) => {
const isDisabled = loginPending || !isMPCLoginEnabled

const login = async () => {
const success = await triggerLogin()
const status = await triggerLogin()

if (success) {
if (status === COREKIT_STATUS.LOGGED_IN) {
onLogin?.()
}
}

const recoverPassword = async (password: string, storeDeviceFactor: boolean) => {
const success = await recoverFactorWithPassword(password, storeDeviceFactor)

if (success) {
onLogin?.()
if (status === COREKIT_STATUS.REQUIRED_SHARE) {
setTxFlow(
<PasswordRecovery recoverFactorWithPassword={recoverPassword} onSuccess={onLogin} />,
() => setWalletState(MPCWalletState.NOT_INITIALIZED),
false,
)
}
}

const recoverPassword = useCallback(
async (password: string, storeDeviceFactor: boolean) => {
const success = await recoverFactorWithPassword(password, storeDeviceFactor)

if (success) {
onLogin?.()
setTxFlow(undefined)
}
},
[onLogin, recoverFactorWithPassword, setTxFlow],
)

const isSocialLogin = isSocialLoginWallet(wallet?.label)

return (
Expand Down Expand Up @@ -128,10 +144,6 @@ const MPCLogin = ({ onLogin }: { onLogin?: () => void }) => {
Currently only supported on {supportedChains.join(', ')}
</Typography>
)}

{walletState === MPCWalletState.MANUAL_RECOVERY && (
<PasswordRecovery recoverFactorWithPassword={recoverPassword} />
)}
</>
)
}
Expand Down
6 changes: 4 additions & 2 deletions src/components/common/ConnectWallet/MPCWalletProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { useMPCWallet, MPCWalletState, type MPCWalletHook } from '@/hooks/wallets/mpc/useMPCWallet'
import { type MPCWalletHook, MPCWalletState, useMPCWallet } from '@/hooks/wallets/mpc/useMPCWallet'
import { createContext, type ReactElement } from 'react'
import { COREKIT_STATUS } from '@web3auth/mpc-core-kit'

export const MpcWalletContext = createContext<MPCWalletHook>({
walletState: MPCWalletState.NOT_INITIALIZED,
triggerLogin: () => Promise.resolve(false),
setWalletState: () => {},
triggerLogin: () => Promise.resolve(COREKIT_STATUS.NOT_INITIALIZED),
resetAccount: () => Promise.resolve(),
upsertPasswordBackup: () => Promise.resolve(),
recoverFactorWithPassword: () => Promise.resolve(false),
Expand Down
136 changes: 86 additions & 50 deletions src/components/common/ConnectWallet/PasswordRecovery.tsx
Original file line number Diff line number Diff line change
@@ -1,70 +1,106 @@
import { MPC_WALLET_EVENTS } from '@/services/analytics/events/mpcWallet'
import { VisibilityOff, Visibility } from '@mui/icons-material'
import {
DialogContent,
Typography,
TextField,
IconButton,
FormControlLabel,
Checkbox,
Button,
Box,
Divider,
Grid,
LinearProgress,
FormControl,
} from '@mui/material'
import { useState } from 'react'
import ModalDialog from '../ModalDialog'
import Track from '../Track'
import { FormProvider, useForm } from 'react-hook-form'
import PasswordInput from '@/components/settings/SignerAccountMFA/PasswordInput'

type PasswordFormData = {
password: string
}

export const PasswordRecovery = ({
recoverFactorWithPassword,
onSuccess,
}: {
recoverFactorWithPassword: (password: string, storeDeviceFactor: boolean) => Promise<void>
onSuccess: (() => void) | undefined
}) => {
const [showPassword, setShowPassword] = useState(false)
const [recoveryPassword, setRecoveryPassword] = useState<string>('')
const [storeDeviceFactor, setStoreDeviceFactor] = useState(false)

const formMethods = useForm<PasswordFormData>({
mode: 'all',
defaultValues: {
password: '',
},
})

const { handleSubmit, formState, setError } = formMethods

const onSubmit = async (data: PasswordFormData) => {
try {
await recoverFactorWithPassword(data.password, storeDeviceFactor)
onSuccess?.()
} catch (e) {
setError('password', { type: 'custom', message: 'Incorrect password' })
}
}

const isDisabled = formState.isSubmitting

return (
<ModalDialog open dialogTitle="Enter your recovery password" hideChainIndicator>
<DialogContent>
<Box>
<Typography>
This browser is not registered with your Account yet. Please enter your recovery password to restore access
to this Account.
</Typography>
<Box mt={2} display="flex" flexDirection="column" alignItems="baseline" gap={2}>
<TextField
label="Recovery password"
type={showPassword ? 'text' : 'password'}
value={recoveryPassword}
onChange={(event) => {
setRecoveryPassword(event.target.value)
}}
InputProps={{
endAdornment: (
<IconButton
aria-label="toggle password visibility"
onClick={() => setShowPassword((prev) => !prev)}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
),
}}
/>
<FormControlLabel
control={<Checkbox checked={storeDeviceFactor} onClick={() => setStoreDeviceFactor((prev) => !prev)} />}
label="Do not ask again on this device"
/>
<Track {...MPC_WALLET_EVENTS.RECOVER_PASSWORD}>
<Button
variant="contained"
onClick={() => recoverFactorWithPassword(recoveryPassword, storeDeviceFactor)}
>
Submit
</Button>
</Track>
</Box>
</Box>
</DialogContent>
</ModalDialog>
<FormProvider {...formMethods}>
<form onSubmit={handleSubmit(onSubmit)}>
<Grid container justifyContent="center" alignItems="center">
<Grid item xs={12} md={5} p={2}>
<Typography variant="h2" fontWeight="bold" mb={3}>
Verify your account
</Typography>
<Box bgcolor="background.paper" borderRadius={1}>
<LinearProgress
color="secondary"
sx={{ borderTopLeftRadius: '6px', borderTopRightRadius: '6px', opacity: isDisabled ? 1 : 0 }}
/>
<Box p={4}>
<Typography variant="h6" fontWeight="bold" mb={0.5}>
Enter security password
</Typography>
<Typography>
This browser is not registered with your Account yet. Please enter your recovery password to restore
access to this Account.
</Typography>
</Box>
<Divider />
<Box p={4} display="flex" flexDirection="column" alignItems="baseline" gap={1}>
<FormControl fullWidth>
<PasswordInput
name="password"
label="Recovery password"
helperText={formState.errors['password']?.message}
disabled={isDisabled}
required
/>
</FormControl>
<FormControlLabel
disabled={isDisabled}
control={
<Checkbox checked={storeDeviceFactor} onClick={() => setStoreDeviceFactor((prev) => !prev)} />
}
label="Do not ask again on this device"
/>
</Box>
<Divider />
<Box p={4} display="flex" justifyContent="flex-end">
<Track {...MPC_WALLET_EVENTS.RECOVER_PASSWORD}>
<Button variant="contained" type="submit" disabled={isDisabled}>
Submit
</Button>
</Track>
</Box>
</Box>
</Grid>
</Grid>
</form>
</FormProvider>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { type EIP1193Provider } from '@web3-onboard/common'
import { ONBOARD_MPC_MODULE_LABEL } from '@/services/mpc/module'
import { MpcWalletProvider } from '../MPCWalletProvider'
import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk'
import { COREKIT_STATUS } from '@web3auth/mpc-core-kit'

describe('MPCLogin', () => {
beforeEach(() => {
Expand Down Expand Up @@ -62,7 +63,7 @@ describe('MPCLogin', () => {
.spyOn(chains, 'useCurrentChain')
.mockReturnValue({ chainId: '100', disabledWallets: [] } as unknown as ChainInfo)
jest.spyOn(useWallet, 'default').mockReturnValue(null)
const mockTriggerLogin = jest.fn(() => true)
const mockTriggerLogin = jest.fn(() => COREKIT_STATUS.LOGGED_IN)
jest.spyOn(useMPCWallet, 'useMPCWallet').mockReturnValue({
triggerLogin: mockTriggerLogin,
} as unknown as useMPCWallet.MPCWalletHook)
Expand Down
2 changes: 1 addition & 1 deletion src/components/common/EthHashInfo/SrcEthHashInfo/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ const SrcEthHashInfo = ({

<Box overflow="hidden">
{name && (
<Box sx={{ fontSize: 'body2' }} textOverflow="ellipsis" overflow="hidden" title={name}>
<Box textOverflow="ellipsis" overflow="hidden" title={name}>
{name}
</Box>
)}
Expand Down
1 change: 1 addition & 0 deletions src/components/tx-flow/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const TxModalContext = createContext<TxModalContextType>({
setFullWidth: noop,
})

// TODO: Rename TxModalProvider, setTxFlow, TxModalDialog to not contain Tx since it can be used for any type of modal as a global provider
export const TxModalProvider = ({ children }: { children: ReactNode }): ReactElement => {
const [txFlow, setFlow] = useState<TxModalContextType['txFlow']>(undefined)
const [shouldWarn, setShouldWarn] = useState<boolean>(true)
Expand Down
12 changes: 9 additions & 3 deletions src/hooks/wallets/mpc/__tests__/useMPCWallet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,9 @@ describe('useMPCWallet', () => {
)
const { result } = renderHook(() => useMPCWallet())

let status: Promise<COREKIT_STATUS>
act(() => {
result.current.triggerLogin()
status = result.current.triggerLogin()
})

// While the login resolves we are in Authenticating state
Expand All @@ -128,6 +129,7 @@ describe('useMPCWallet', () => {

// We should be logged in and onboard should get connected
await waitFor(() => {
expect(status).resolves.toEqual(COREKIT_STATUS.LOGGED_IN)
expect(result.current.walletState === MPCWalletState.READY)
expect(connectWalletSpy).toBeCalledWith(expect.anything(), {
autoSelect: {
Expand Down Expand Up @@ -158,8 +160,9 @@ describe('useMPCWallet', () => {

const { result } = renderHook(() => useMPCWallet())

let status: Promise<COREKIT_STATUS>
act(() => {
result.current.triggerLogin()
status = result.current.triggerLogin()
})

// While the login resolves we are in Authenticating state
Expand All @@ -173,6 +176,7 @@ describe('useMPCWallet', () => {

// We should be logged in and onboard should get connected
await waitFor(() => {
expect(status).resolves.toEqual(COREKIT_STATUS.LOGGED_IN)
expect(result.current.walletState === MPCWalletState.READY)
expect(connectWalletSpy).toBeCalledWith(expect.anything(), {
autoSelect: {
Expand Down Expand Up @@ -201,8 +205,9 @@ describe('useMPCWallet', () => {

const { result } = renderHook(() => useMPCWallet())

let status: Promise<COREKIT_STATUS>
act(() => {
result.current.triggerLogin()
status = result.current.triggerLogin()
})

// While the login resolves we are in Authenticating state
Expand All @@ -216,6 +221,7 @@ describe('useMPCWallet', () => {

// A missing second factor should result in manual recovery state
await waitFor(() => {
expect(status).resolves.toEqual(COREKIT_STATUS.REQUIRED_SHARE)
expect(result.current.walletState === MPCWalletState.MANUAL_RECOVERY)
expect(connectWalletSpy).not.toBeCalled()
})
Expand Down
12 changes: 7 additions & 5 deletions src/hooks/wallets/mpc/useMPCWallet.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { type Dispatch, type SetStateAction, useState } from 'react'
import useMPC from './useMPC'
import BN from 'bn.js'
import { GOOGLE_CLIENT_ID, WEB3AUTH_VERIFIER_ID } from '@/config/constants'
Expand All @@ -21,7 +21,8 @@ export type MPCWalletHook = {
upsertPasswordBackup: (password: string) => Promise<void>
recoverFactorWithPassword: (password: string, storeDeviceShare: boolean) => Promise<boolean>
walletState: MPCWalletState
triggerLogin: () => Promise<boolean>
setWalletState: Dispatch<SetStateAction<MPCWalletState>>
triggerLogin: () => Promise<COREKIT_STATUS>
resetAccount: () => Promise<void>
userInfo: UserInfo | undefined
exportPk: (password: string) => Promise<string | undefined>
Expand Down Expand Up @@ -78,17 +79,17 @@ export const useMPCWallet = (): MPCWalletHook => {
if (securityQuestions.isEnabled()) {
trackEvent(MPC_WALLET_EVENTS.MANUAL_RECOVERY)
setWalletState(MPCWalletState.MANUAL_RECOVERY)
return false
return mpcCoreKit.status
}
}
}

await finalizeLogin()
return mpcCoreKit.status === COREKIT_STATUS.LOGGED_IN
return mpcCoreKit.status
} catch (error) {
setWalletState(MPCWalletState.NOT_INITIALIZED)
console.error(error)
return false
return mpcCoreKit.status
}
}

Expand Down Expand Up @@ -154,6 +155,7 @@ export const useMPCWallet = (): MPCWalletHook => {
return {
triggerLogin,
walletState,
setWalletState,
recoverFactorWithPassword,
resetAccount: criticalResetAccount,
upsertPasswordBackup: () => Promise.resolve(),
Expand Down

0 comments on commit 8500cdf

Please sign in to comment.