From 63badcec888695bd3ca8be37d70c6522f70caf49 Mon Sep 17 00:00:00 2001 From: Pedro Nauck Date: Thu, 13 Oct 2022 21:01:32 -0700 Subject: [PATCH] feat(app): add Unlock dialog (#140) feat(app): add UnlockDialog component --- packages/app/src/config.ts | 1 + packages/app/src/routes.tsx | 10 +-- .../systems/Account/__mocks__/accounts.tsx | 26 ++++++ .../UnlockDialog/UnlockDialog.stories.tsx | 27 ++++++ .../components/UnlockDialog/UnlockDialog.tsx | 85 +++++++++++++++++++ .../Account/components/UnlockDialog/index.tsx | 1 + .../components/UnlockForm/UnlockForm.tsx | 29 +++++++ .../Account/components/UnlockForm/index.tsx | 1 + .../src/systems/Account/components/index.tsx | 2 + .../app/src/systems/Account/hooks/index.tsx | 1 + .../src/systems/Account/hooks/useAccount.tsx | 20 ++++- .../systems/Account/hooks/useUnlockForm.tsx | 42 +++++++++ .../Account/machines/accountMachine.tsx | 73 +++++++++++++--- .../src/systems/Account/services/account.ts | 11 +++ .../systems/Core/components/Layout/TopBar.tsx | 10 +-- .../components/Mnemonic/Mnemonic.stories.tsx | 2 +- 16 files changed, 313 insertions(+), 28 deletions(-) create mode 100644 packages/app/src/systems/Account/components/UnlockDialog/UnlockDialog.stories.tsx create mode 100644 packages/app/src/systems/Account/components/UnlockDialog/UnlockDialog.tsx create mode 100644 packages/app/src/systems/Account/components/UnlockDialog/index.tsx create mode 100644 packages/app/src/systems/Account/components/UnlockForm/UnlockForm.tsx create mode 100644 packages/app/src/systems/Account/components/UnlockForm/index.tsx create mode 100644 packages/app/src/systems/Account/hooks/useUnlockForm.tsx diff --git a/packages/app/src/config.ts b/packages/app/src/config.ts index c011835b9..21134abac 100644 --- a/packages/app/src/config.ts +++ b/packages/app/src/config.ts @@ -18,6 +18,7 @@ export const WALLET_HEIGHT = 600; export const TAB_BAR_HEIGHT = 30; export const IS_CRX = VITE_CRX === 'true'; export const IS_LOGGED_KEY = 'fuel__isLogged'; +export const IS_LOCKED_KEY = 'fuel__isLocked'; export const IS_DEVELOPMENT = process.env.NODE_ENV !== 'production'; export const IS_CRX_POPUP = IS_CRX && globalThis.location.pathname === CRXPages.popup; diff --git a/packages/app/src/routes.tsx b/packages/app/src/routes.tsx index 2ec634b7e..bdf090bfd 100644 --- a/packages/app/src/routes.tsx +++ b/packages/app/src/routes.tsx @@ -2,14 +2,14 @@ import { Navigate, Route, Routes } from 'react-router-dom'; import { IS_CRX, IS_CRX_POPUP } from './config'; import { CRXPrivateRoute, CRXPublicRoute } from './systems/CRX/components'; -import { WalletCreatedPage } from './systems/SignUp/pages'; import { PrivateRoute, PublicRoute } from '~/systems/Core'; import { Pages } from '~/systems/Core/types'; -import { homeRoutes } from '~/systems/Home/routes'; -import { landingPageRoutes } from '~/systems/LandingPage/routes'; -import { networkRoutes } from '~/systems/Network/routes'; -import { signUpRoutes } from '~/systems/SignUp/routes'; +import { homeRoutes } from '~/systems/Home'; +import { landingPageRoutes } from '~/systems/LandingPage'; +import { networkRoutes } from '~/systems/Network'; +import { signUpRoutes } from '~/systems/SignUp'; +import { WalletCreatedPage } from '~/systems/SignUp/pages'; const walletRoutes = ( <> diff --git a/packages/app/src/systems/Account/__mocks__/accounts.tsx b/packages/app/src/systems/Account/__mocks__/accounts.tsx index 15d4ee57d..3543b0dc2 100644 --- a/packages/app/src/systems/Account/__mocks__/accounts.tsx +++ b/packages/app/src/systems/Account/__mocks__/accounts.tsx @@ -1,3 +1,10 @@ +import { Mnemonic } from '@fuel-ts/mnemonic'; + +import { AccountService } from '../services'; + +import { MNEMONIC_SIZE } from '~/config'; +import { getWordsFromValue } from '~/systems/Core'; + export const MOCK_ACCOUNTS = [ { name: 'Account 1', @@ -16,3 +23,22 @@ export const MOCK_ACCOUNTS = [ publicKey: '0x00', }, ]; + +export async function createMockAccount(password: string) { + const mnemonic = getWordsFromValue(Mnemonic.generate(MNEMONIC_SIZE)); + const manager = await AccountService.createManager({ + data: { + mnemonic, + password, + }, + }); + const firstAccount = manager.getAccounts()[0]; + await AccountService.clearAccounts(); + return AccountService.addAccount({ + data: { + ...MOCK_ACCOUNTS[0], + address: firstAccount.address.toString(), + publicKey: firstAccount.publicKey, + }, + }); +} diff --git a/packages/app/src/systems/Account/components/UnlockDialog/UnlockDialog.stories.tsx b/packages/app/src/systems/Account/components/UnlockDialog/UnlockDialog.stories.tsx new file mode 100644 index 000000000..e488eda2f --- /dev/null +++ b/packages/app/src/systems/Account/components/UnlockDialog/UnlockDialog.stories.tsx @@ -0,0 +1,27 @@ +import type { Story } from '@storybook/react'; + +import { createMockAccount } from '../../__mocks__'; + +import { UnlockDialog } from './UnlockDialog'; + +export default { + component: UnlockDialog, + title: 'Account/Components/UnlockDialog', + parameters: { + layout: 'fullscreen', + }, +}; + +export const Usage: Story = () => { + return ; +}; + +Usage.loaders = [ + async () => { + await createMockAccount('123123123'); + return {}; + }, +]; +Usage.parameters = { + layout: 'centered', +}; diff --git a/packages/app/src/systems/Account/components/UnlockDialog/UnlockDialog.tsx b/packages/app/src/systems/Account/components/UnlockDialog/UnlockDialog.tsx new file mode 100644 index 000000000..01ba8b128 --- /dev/null +++ b/packages/app/src/systems/Account/components/UnlockDialog/UnlockDialog.tsx @@ -0,0 +1,85 @@ +import { cssObj } from '@fuel-ui/css'; +import { Alert, Button, Dialog, Flex, Icon, Stack } from '@fuel-ui/react'; + +import type { UnlockFormValues } from '../../hooks'; +import { useAccount, useUnlockForm } from '../../hooks'; +import { UnlockForm } from '../UnlockForm'; + +export type UnlockDialogProps = { + isOpen?: boolean; + onOpenChange?: (open: boolean) => void; +}; + +export function UnlockDialog({ isOpen, onOpenChange }: UnlockDialogProps) { + const form = useUnlockForm(); + const { handlers, isLoading } = useAccount(); + const { formState } = form; + + function onSubmit(values: UnlockFormValues) { + handlers.unlock(values.password); + } + + return ( + +
+ + + + + Unlock Wallet + + + + + + You need to unlock your wallet to be able to make transactions + and more-sensitive actions. + + + + + + + + + + +
+
+ ); +} + +const styles = { + headingIcon: cssObj({ + marginRight: '$3', + }), + alert: cssObj({ + py: '$2', + pr: '$2', + background: '$gray2', + }), + button: cssObj({ + width: '100%', + }), + content: cssObj({ + maxWidth: 312, + + /** This is temporary until have this option on @fuel-ui */ + 'button[aria-label="Close"]': { + display: 'none', + }, + }), +}; diff --git a/packages/app/src/systems/Account/components/UnlockDialog/index.tsx b/packages/app/src/systems/Account/components/UnlockDialog/index.tsx new file mode 100644 index 000000000..80a0c6ce8 --- /dev/null +++ b/packages/app/src/systems/Account/components/UnlockDialog/index.tsx @@ -0,0 +1 @@ +export * from './UnlockDialog'; diff --git a/packages/app/src/systems/Account/components/UnlockForm/UnlockForm.tsx b/packages/app/src/systems/Account/components/UnlockForm/UnlockForm.tsx new file mode 100644 index 000000000..f60a4c2fc --- /dev/null +++ b/packages/app/src/systems/Account/components/UnlockForm/UnlockForm.tsx @@ -0,0 +1,29 @@ +import { InputPassword, Stack } from '@fuel-ui/react'; + +import type { UseUnlockFormReturn } from '../../hooks/useUnlockForm'; + +import { ControlledField } from '~/systems/Core'; + +type UnlockFormProps = { + form: UseUnlockFormReturn; +}; + +export function UnlockForm({ form }: UnlockFormProps) { + const { control } = form; + return ( + + ( + + )} + /> + + ); +} diff --git a/packages/app/src/systems/Account/components/UnlockForm/index.tsx b/packages/app/src/systems/Account/components/UnlockForm/index.tsx new file mode 100644 index 000000000..12d7bba9f --- /dev/null +++ b/packages/app/src/systems/Account/components/UnlockForm/index.tsx @@ -0,0 +1 @@ +export * from './UnlockForm'; diff --git a/packages/app/src/systems/Account/components/index.tsx b/packages/app/src/systems/Account/components/index.tsx index 9adee1322..bd74fcf72 100644 --- a/packages/app/src/systems/Account/components/index.tsx +++ b/packages/app/src/systems/Account/components/index.tsx @@ -1,3 +1,5 @@ export * from './AccountItem'; export * from './AccountList'; export * from './BalanceWidget'; +export * from './UnlockDialog'; +export * from './UnlockForm'; diff --git a/packages/app/src/systems/Account/hooks/index.tsx b/packages/app/src/systems/Account/hooks/index.tsx index 5366ad49a..5f2023138 100644 --- a/packages/app/src/systems/Account/hooks/index.tsx +++ b/packages/app/src/systems/Account/hooks/index.tsx @@ -1 +1,2 @@ export * from './useAccount'; +export * from './useUnlockForm'; diff --git a/packages/app/src/systems/Account/hooks/useAccount.tsx b/packages/app/src/systems/Account/hooks/useAccount.tsx index c9d1f830e..bd03e57c1 100644 --- a/packages/app/src/systems/Account/hooks/useAccount.tsx +++ b/packages/app/src/systems/Account/hooks/useAccount.tsx @@ -9,14 +9,32 @@ const selectors = { account: (state: AccountMachineState) => { return state.context?.data; }, + isLocked: (state: AccountMachineState) => { + return !state.context?.wallet; + }, + wallet: (state: AccountMachineState) => { + return state.context?.wallet; + }, }; export function useAccount() { + const service = store.useService(Services.account); const isLoading = store.useSelector(Services.account, selectors.isLoading); const account = store.useSelector(Services.account, selectors.account); + const isLocked = store.useSelector(Services.account, selectors.isLocked); + const wallet = store.useSelector(Services.account, selectors.wallet); + + function unlock(password: string) { + service.send('UNLOCK_WALLET', { input: { password, account } }); + } return { - isLoading, account, + wallet, + isLoading, + isLocked, + handlers: { + unlock, + }, }; } diff --git a/packages/app/src/systems/Account/hooks/useUnlockForm.tsx b/packages/app/src/systems/Account/hooks/useUnlockForm.tsx new file mode 100644 index 000000000..de9656526 --- /dev/null +++ b/packages/app/src/systems/Account/hooks/useUnlockForm.tsx @@ -0,0 +1,42 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { useForm } from 'react-hook-form'; +import { useNavigate, useLocation } from 'react-router-dom'; +import * as yup from 'yup'; + +import { store, Services } from '~/store'; +import { Pages } from '~/systems/Core'; + +const schema = yup + .object({ + password: yup.string().min(8).required('Password is required'), + }) + .required(); + +export type UseUnlockFormReturn = ReturnType; + +export type UnlockFormValues = { + password: string; +}; + +export function useUnlockForm() { + const navigate = useNavigate(); + const location = useLocation(); + const form = useForm({ + resolver: yupResolver(schema), + reValidateMode: 'onChange', + mode: 'onChange', + defaultValues: { + password: '', + }, + }); + + store.useSetMachineConfig(Services.account, { + actions: { + redirectToStatePath() { + navigate(location.state?.lastPage ?? Pages.wallet()); + }, + }, + }); + + return form; +} diff --git a/packages/app/src/systems/Account/machines/accountMachine.tsx b/packages/app/src/systems/Account/machines/accountMachine.tsx index 27e685ed6..601067858 100644 --- a/packages/app/src/systems/Account/machines/accountMachine.tsx +++ b/packages/app/src/systems/Account/machines/accountMachine.tsx @@ -1,13 +1,17 @@ +import type { Wallet } from 'fuels'; import type { InterpreterFrom, StateFrom } from 'xstate'; import { assign, createMachine } from 'xstate'; +import type { AccountInputs } from '../services/account'; import { AccountService } from '../services/account'; import type { Account } from '../types'; import { IS_LOGGED_KEY } from '~/config'; +import { FetchMachine } from '~/systems/Core'; import { NetworkService } from '~/systems/Network'; type MachineContext = { + wallet?: Wallet; data?: Account; error?: unknown; }; @@ -16,9 +20,14 @@ type MachineServices = { fetchAccount: { data: Account; }; + unlock: { + data: Wallet; + }; }; -type MachineEvents = { type: 'UPDATE_ACCOUNT' }; +type MachineEvents = + | { type: 'UPDATE_ACCOUNT'; input?: null } + | { type: 'UNLOCK_WALLET'; input: AccountInputs['unlock'] }; export const accountMachine = createMachine( { @@ -34,6 +43,7 @@ export const accountMachine = createMachine( initial: 'fetchingAccount', states: { fetchingAccount: { + tags: ['loading'], invoke: { src: 'fetchAccount', onDone: [ @@ -54,7 +64,25 @@ export const accountMachine = createMachine( }, ], }, - tags: 'loading', + }, + unlocking: { + tags: ['loading'], + invoke: { + src: 'unlock', + data: { + input: (_: MachineContext, ev: MachineEvents) => ev.input, + }, + onDone: [ + { + target: 'done', + cond: FetchMachine.hasError, + }, + { + actions: ['assignWallet', 'redirectToStatePath'], + target: 'done', + }, + ], + }, }, done: {}, failed: {}, @@ -63,6 +91,9 @@ export const accountMachine = createMachine( UPDATE_ACCOUNT: { target: 'fetchingAccount', }, + UNLOCK_WALLET: { + target: 'unlocking', + }, }, }, { @@ -70,6 +101,9 @@ export const accountMachine = createMachine( assignAccount: assign({ data: (_, ev) => ev.data, }), + assignWallet: assign({ + wallet: (_, ev) => ev.data, + }), assignError: assign({ error: (_, ev) => ev.data, }), @@ -79,19 +113,32 @@ export const accountMachine = createMachine( removeLocalStorage: () => { localStorage.removeItem(IS_LOGGED_KEY); }, + redirectToStatePath() {}, }, services: { - async fetchAccount() { - const selectedNetwork = await NetworkService.getSelectedNetwork(); - const defaultProvider = import.meta.env.VITE_FUEL_PROVIDER_URL; - const providerUrl = selectedNetwork?.url || defaultProvider; - const accounts = await AccountService.getAccounts(); - const account = accounts[0]; - if (!account) { - throw new Error('Account not found'); - } - return AccountService.fetchBalance({ account, providerUrl }); - }, + fetchAccount: FetchMachine.create({ + showError: true, + async fetch() { + const selectedNetwork = await NetworkService.getSelectedNetwork(); + const defaultProvider = import.meta.env.VITE_FUEL_PROVIDER_URL; + const providerUrl = selectedNetwork?.url || defaultProvider; + const accounts = await AccountService.getAccounts(); + const account = accounts[0]; + if (!account) { + throw new Error('Account not found'); + } + return AccountService.fetchBalance({ account, providerUrl }); + }, + }), + unlock: FetchMachine.create({ + showError: true, + async fetch({ input }) { + if (!input || !input?.password) { + throw new Error('Invalid network input'); + } + return AccountService.unlock(input); + }, + }), }, guards: { hasAccount: (ctx, ev) => { diff --git a/packages/app/src/systems/Account/services/account.ts b/packages/app/src/systems/Account/services/account.ts index f73cd47b6..11301dfc3 100644 --- a/packages/app/src/systems/Account/services/account.ts +++ b/packages/app/src/systems/Account/services/account.ts @@ -30,6 +30,10 @@ export type AccountInputs = { mnemonic?: string[]; }; }; + unlock: { + account: Account; + password: string; + }; }; export class AccountService { @@ -133,6 +137,13 @@ export class AccountService { throw error; } } + + static async unlock(input: AccountInputs['unlock']) { + const storage = new IndexedDBStorage() as never; + const manager = new WalletManager({ storage }); + await manager.unlock(input.password); + return manager.getWallet(Address.fromPublicKey(input.account.publicKey)); + } } // ---------------------------------------------------------------------------- diff --git a/packages/app/src/systems/Core/components/Layout/TopBar.tsx b/packages/app/src/systems/Core/components/Layout/TopBar.tsx index ff729cc53..c677536db 100644 --- a/packages/app/src/systems/Core/components/Layout/TopBar.tsx +++ b/packages/app/src/systems/Core/components/Layout/TopBar.tsx @@ -26,12 +26,12 @@ type TopBarProps = { }; export function TopBar({ onBack }: TopBarProps) { + const navigate = useNavigate(); const { isLoading, title, isHome } = useLayoutContext(); + const isInternal = !isHome; const { networks, selectedNetwork, handlers } = useNetworks({ type: NetworkScreen.list, }); - const navigate = useNavigate(); - const isInternal = !isHome; return ( @@ -63,12 +63,6 @@ export function TopBar({ onBack }: TopBarProps) { )} - } - aria-label="Activities" - variant="link" - css={{ px: '0 !important' }} - /> } aria-label="Open menu" diff --git a/packages/app/src/systems/Core/components/Mnemonic/Mnemonic.stories.tsx b/packages/app/src/systems/Core/components/Mnemonic/Mnemonic.stories.tsx index d413404b9..e88e2c0ca 100644 --- a/packages/app/src/systems/Core/components/Mnemonic/Mnemonic.stories.tsx +++ b/packages/app/src/systems/Core/components/Mnemonic/Mnemonic.stories.tsx @@ -12,7 +12,7 @@ export default { component: Mnemonic, title: 'Core/Components/Mnemonic', parameters: { - Mnemonic: 'fullscreen', + layout: 'fullscreen', }, };