diff --git a/apps/web/src/assets/icons/index.ts b/apps/web/src/assets/icons/index.ts index a7dc5f3ac7..320d0b0dc8 100644 --- a/apps/web/src/assets/icons/index.ts +++ b/apps/web/src/assets/icons/index.ts @@ -10,10 +10,10 @@ export { default as ChevronDownIcon } from "./chevron-down.svg"; export { default as ChevronRightIcon } from "./chevron-right.svg"; export { default as CloseIcon } from "./close.svg"; export { default as CodeSandboxIcon } from "./code-sandbox.svg"; +export { default as CoinIcon } from "./coin.svg"; export { default as ContactIcon } from "./contact-s.svg"; export { default as ContractIcon } from "./contract.svg"; export { default as CopyIcon } from "./copy.svg"; -export { default as CoinIcon } from "./coin.svg"; export { default as CrossedCircleIcon } from "./crossed-circle.svg"; export { default as DelegateIcon } from "./delegate-s.svg"; export { default as DownloadIcon } from "./download.svg"; @@ -42,10 +42,12 @@ export { default as MenuIcon } from "./menu.svg"; export { default as MoonIcon } from "./moon.svg"; export { default as MultisigIcon } from "./multisig.svg"; export { default as OutgoingArrowIcon } from "./outgoing-arrow.svg"; +export { default as PencilIcon } from "./pencil.svg"; export { default as PlusIcon } from "./plus.svg"; export { default as PyramidIcon } from "./pyramid.svg"; export { default as RadioIcon } from "./radio.svg"; export { default as RedditIcon } from "./reddit.svg"; +export { default as ScanIcon } from "./scan.svg"; export { default as SearchIcon } from "./search.svg"; export { default as SelectorIcon } from "./selector.svg"; export { default as SettingsIcon } from "./settings.svg"; @@ -60,6 +62,7 @@ export { default as UserIcon } from "./user.svg"; export { default as UserPlusIcon } from "./user-plus.svg"; export { default as VerifiedIcon } from "./verified.svg"; export { default as WalletIcon } from "./wallet.svg"; +export { default as WindowCloseIcon } from "./window-close.svg"; export { TokenIcon } from "./TokenIcon"; import CloseIcon from "./close.svg"; diff --git a/apps/web/src/assets/icons/pencil.svg b/apps/web/src/assets/icons/pencil.svg new file mode 100644 index 0000000000..0d3958d99d --- /dev/null +++ b/apps/web/src/assets/icons/pencil.svg @@ -0,0 +1,5 @@ + + + diff --git a/apps/web/src/assets/icons/scan.svg b/apps/web/src/assets/icons/scan.svg new file mode 100644 index 0000000000..ce767b480b --- /dev/null +++ b/apps/web/src/assets/icons/scan.svg @@ -0,0 +1,5 @@ + + + diff --git a/apps/web/src/assets/icons/window-close.svg b/apps/web/src/assets/icons/window-close.svg new file mode 100644 index 0000000000..e113c889c2 --- /dev/null +++ b/apps/web/src/assets/icons/window-close.svg @@ -0,0 +1,8 @@ + + + + diff --git a/apps/web/src/components/AccountCard/AccountBalance.tsx b/apps/web/src/components/AccountCard/AccountBalance.tsx index 8ef4ab4645..6c8cd7b0a0 100644 --- a/apps/web/src/components/AccountCard/AccountBalance.tsx +++ b/apps/web/src/components/AccountCard/AccountBalance.tsx @@ -8,7 +8,7 @@ import { ArrowDownLeftIcon, WalletIcon } from "../../assets/icons"; import { useColor } from "../../styles/useColor"; import { AccountInfoModal } from "../AccountSelectorModal"; import { IconButtonWithText } from "../IconButtonWithText"; -import { useIsAccountVerified } from "../Onboarding/useIsAccountVerified"; +import { useIsAccountVerified } from "../Onboarding/VerificationFlow"; export const AccountBalance = () => { const color = useColor(); diff --git a/apps/web/src/components/AccountCard/SendTezButton.tsx b/apps/web/src/components/AccountCard/SendTezButton.tsx index e3252d5e6e..1fd8558a89 100644 --- a/apps/web/src/components/AccountCard/SendTezButton.tsx +++ b/apps/web/src/components/AccountCard/SendTezButton.tsx @@ -3,7 +3,7 @@ import { useCurrentAccount } from "@umami/state"; import { ArrowUpRightIcon } from "../../assets/icons"; import { IconButtonWithText } from "../IconButtonWithText/IconButtonWithText"; -import { useIsAccountVerified } from "../Onboarding/useIsAccountVerified"; +import { useIsAccountVerified } from "../Onboarding/VerificationFlow"; import { FormPage as SendTezFormPage } from "../SendFlow/Tez/FormPage"; export const SendTezButton = () => { diff --git a/apps/web/src/components/AccountSelectorModal/AccountSelectorModal.tsx b/apps/web/src/components/AccountSelectorModal/AccountSelectorModal.tsx index 6c28c16bbc..3247bf317f 100644 --- a/apps/web/src/components/AccountSelectorModal/AccountSelectorModal.tsx +++ b/apps/web/src/components/AccountSelectorModal/AccountSelectorModal.tsx @@ -23,7 +23,7 @@ import { useColor } from "../../styles/useColor"; import { AccountTile } from "../AccountTile"; import { ModalCloseButton } from "../CloseButton"; import { OnboardOptionsModal } from "../Onboarding/OnboardOptions"; -import { useIsAccountVerified } from "../Onboarding/useIsAccountVerified"; +import { useIsAccountVerified } from "../Onboarding/VerificationFlow"; export const AccountSelectorModal = () => { const accounts = useImplicitAccounts(); diff --git a/apps/web/src/components/EmptyMessage/VerifyMessage.test.tsx b/apps/web/src/components/EmptyMessage/VerifyMessage.test.tsx deleted file mode 100644 index a0d4c64a8e..0000000000 --- a/apps/web/src/components/EmptyMessage/VerifyMessage.test.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { VerifyMessage } from "./VerifyMessage"; -import { dynamicModalContextMock, render, screen, userEvent } from "../../testUtils"; -import { VerificationInfoModal } from "../Onboarding/VerificationInfoModal"; - -describe("", () => { - it("renders correctly", () => { - render(); - - expect(screen.getByText("Verify Your Account")).toBeVisible(); - expect( - screen.getByText( - "Please verify your account, to unlock all features and keep your account secure." - ) - ).toBeVisible(); - expect(screen.getByText("How does verification work?")).toBeVisible(); - }); - - it("opens verification info modal", async () => { - const { openWith } = dynamicModalContextMock; - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText("How does verification work?")); - - expect(openWith).toHaveBeenCalledWith(); - }); -}); diff --git a/apps/web/src/components/EmptyMessage/index.ts b/apps/web/src/components/EmptyMessage/index.ts index 3913fd280f..af14d53027 100644 --- a/apps/web/src/components/EmptyMessage/index.ts +++ b/apps/web/src/components/EmptyMessage/index.ts @@ -1,2 +1,2 @@ export { EmptyMessage } from "./EmptyMessage"; -export { VerifyMessage } from "./VerifyMessage"; +export type { EmptyMessageProps } from "./EmptyMessage"; diff --git a/apps/web/src/components/Menu/AdvancedMenu/AdvancedMenu.tsx b/apps/web/src/components/Menu/AdvancedMenu/AdvancedMenu.tsx index 5760da093d..2657db38d1 100644 --- a/apps/web/src/components/Menu/AdvancedMenu/AdvancedMenu.tsx +++ b/apps/web/src/components/Menu/AdvancedMenu/AdvancedMenu.tsx @@ -1,7 +1,7 @@ import { useDynamicDrawerContext } from "@umami/components"; import { AlertCircleIcon, LockIcon, RadioIcon } from "../../../assets/icons"; -import { useIsAccountVerified } from "../../Onboarding/useIsAccountVerified"; +import { useIsAccountVerified } from "../../Onboarding/VerificationFlow"; import { ChangePasswordMenu } from "../ChangePasswordMenu/ChangePasswordMenu"; import { ErrorLogsMenu } from "../ErrorLogsMenu/ErrorLogsMenu"; import { GenericMenu } from "../GenericMenu"; diff --git a/apps/web/src/components/Menu/Menu.tsx b/apps/web/src/components/Menu/Menu.tsx index 3339866e49..92dc455d23 100644 --- a/apps/web/src/components/Menu/Menu.tsx +++ b/apps/web/src/components/Menu/Menu.tsx @@ -18,7 +18,7 @@ import { UserPlusIcon, } from "../../assets/icons"; import { OnboardOptionsModal } from "../Onboarding/OnboardOptions"; -import { useIsAccountVerified } from "../Onboarding/useIsAccountVerified"; +import { useIsAccountVerified } from "../Onboarding/VerificationFlow"; export const Menu = () => { const { openWith: openModal } = useDynamicModalContext(); diff --git a/apps/web/src/components/MnemonicWord/MnemonicWord.tsx b/apps/web/src/components/MnemonicWord/MnemonicWord.tsx new file mode 100644 index 0000000000..5909ed6986 --- /dev/null +++ b/apps/web/src/components/MnemonicWord/MnemonicWord.tsx @@ -0,0 +1,43 @@ +import { GridItem, type GridItemProps, Text } from "@chakra-ui/react"; +import { MnemonicAutocomplete } from "@umami/components"; +import { type ComponentProps } from "react"; + +import { useColor } from "../../styles/useColor"; + +type MnemonicWordProps = { + index: number; + word?: string; + autocompleteProps?: ComponentProps; +} & GridItemProps; + +export const MnemonicWord = ({ index, word, autocompleteProps, ...props }: MnemonicWordProps) => { + const color = useColor(); + + return ( + + + {String(index + 1).padStart(2, "0")}. + + {autocompleteProps && } + {word && ( + + {word} + + )} + + ); +}; diff --git a/apps/web/src/components/MnemonicWord/index.ts b/apps/web/src/components/MnemonicWord/index.ts new file mode 100644 index 0000000000..058f49c522 --- /dev/null +++ b/apps/web/src/components/MnemonicWord/index.ts @@ -0,0 +1 @@ +export { MnemonicWord } from "./MnemonicWord"; diff --git a/apps/web/src/components/Onboarding/ImportWallet/SeedPhraseTab.tsx b/apps/web/src/components/Onboarding/ImportWallet/SeedPhraseTab.tsx index 690dff38c0..77783d5d04 100644 --- a/apps/web/src/components/Onboarding/ImportWallet/SeedPhraseTab.tsx +++ b/apps/web/src/components/Onboarding/ImportWallet/SeedPhraseTab.tsx @@ -7,12 +7,10 @@ import { Button, Flex, Grid, - GridItem, Heading, Icon, - Text, } from "@chakra-ui/react"; -import { MnemonicAutocomplete, useDynamicModalContext, useMultiForm } from "@umami/components"; +import { useDynamicModalContext, useMultiForm } from "@umami/components"; import { useAsyncActionHandler } from "@umami/state"; import { validateMnemonic } from "bip39"; import { range } from "lodash"; @@ -20,6 +18,7 @@ import { FormProvider, useFieldArray } from "react-hook-form"; import { CloseIcon } from "../../../assets/icons"; import { useColor } from "../../../styles/useColor"; +import { MnemonicWord } from "../../MnemonicWord"; import { RadioButtons } from "../../RadioButtons"; import { SetupPassword } from "../SetupPassword"; @@ -114,21 +113,11 @@ export const SeedPhraseTab = () => { {fields.map((field, index) => ( - - - {String(index + 1).padStart(2, "0")}. - - { @@ -137,9 +126,10 @@ export const SeedPhraseTab = () => { }, variant: "mnemonic", placeholder: `word #${index + 1}`, - }} - /> - + }, + }} + index={index} + /> ))} + + + ); +}; diff --git a/apps/web/src/components/Onboarding/VerificationFlow/RecordSeedphraseModal.tsx b/apps/web/src/components/Onboarding/VerificationFlow/RecordSeedphraseModal.tsx new file mode 100644 index 0000000000..3a7a6a3aa2 --- /dev/null +++ b/apps/web/src/components/Onboarding/VerificationFlow/RecordSeedphraseModal.tsx @@ -0,0 +1,87 @@ +import { + Button, + Center, + Flex, + Grid, + Heading, + Icon, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + Text, +} from "@chakra-ui/react"; +import { useDynamicModalContext } from "@umami/components"; + +import { VerifySeedphraseModal } from "./VerifySeedphraseModal"; +import { CopyIcon, KeyIcon } from "../../../assets/icons"; +import { useColor } from "../../../styles/useColor"; +import { ModalBackButton } from "../../BackButton"; +import { ModalCloseButton } from "../../CloseButton"; +import { CopyButton } from "../../CopyButton"; +import { MnemonicWord } from "../../MnemonicWord"; + +type CopySeedphraseModalProps = { + seedPhrase: string; +}; + +export const RecordSeedphraseModal = ({ seedPhrase }: CopySeedphraseModalProps) => { + const color = useColor(); + const { openWith } = useDynamicModalContext(); + const words = seedPhrase.split(" "); + + return ( + + + + +
+ + Record Seed Phrase + + Record these 24 words in order to restore your wallet in the future + +
+
+ + + + {words.map((word, index) => ( + + ))} + + + + Copy + + + + + +
+ ); +}; diff --git a/apps/web/src/components/Onboarding/VerificationInfoModal.tsx b/apps/web/src/components/Onboarding/VerificationFlow/VerificationInfoModal.tsx similarity index 87% rename from apps/web/src/components/Onboarding/VerificationInfoModal.tsx rename to apps/web/src/components/Onboarding/VerificationFlow/VerificationInfoModal.tsx index d1deee2adb..3f23f4242b 100644 --- a/apps/web/src/components/Onboarding/VerificationInfoModal.tsx +++ b/apps/web/src/components/Onboarding/VerificationFlow/VerificationInfoModal.tsx @@ -14,9 +14,10 @@ import { Text, } from "@chakra-ui/react"; -import { AlertIcon } from "../../assets/icons"; -import { useColor } from "../../styles/useColor"; -import { ModalCloseButton } from "../CloseButton"; +import { useHandleVerify } from "./useHandleVerify"; +import { AlertIcon } from "../../../assets/icons"; +import { useColor } from "../../../styles/useColor"; +import { ModalCloseButton } from "../../CloseButton"; // TODO: Replace with actual copy paste const accordionItems = [ @@ -38,6 +39,7 @@ const accordionItems = [ export const VerificationInfoModal = () => { const color = useColor(); + const handleVerify = useHandleVerify(); return ( @@ -78,7 +80,7 @@ export const VerificationInfoModal = () => { - diff --git a/apps/web/src/components/Onboarding/VerificationFlow/VerifyMessage.test.tsx b/apps/web/src/components/Onboarding/VerificationFlow/VerifyMessage.test.tsx new file mode 100644 index 0000000000..875905390c --- /dev/null +++ b/apps/web/src/components/Onboarding/VerificationFlow/VerifyMessage.test.tsx @@ -0,0 +1,54 @@ +import { mockMnemonicAccount } from "@umami/core"; +import { type UmamiStore, addTestAccount, makeStore } from "@umami/state"; + +import { useHandleVerify } from "./useHandleVerify"; +import { VerificationInfoModal } from "./VerificationInfoModal"; +import { VerifyMessage } from "./VerifyMessage"; +import { dynamicModalContextMock, render, screen, userEvent } from "../../../testUtils"; + +let store: UmamiStore; + +jest.mock("./useHandleVerify.tsx", () => ({ + useHandleVerify: jest.fn(), +})); + +beforeEach(() => { + store = makeStore(); + addTestAccount(store, mockMnemonicAccount(0, { isVerified: false })); +}); + +describe("", () => { + it("renders correctly", () => { + render(); + + expect(screen.getByText("Verify Your Account")).toBeVisible(); + expect( + screen.getByText( + "Please verify your account, to unlock all features and keep your account secure." + ) + ).toBeVisible(); + expect(screen.getByText("How does verification work?")).toBeVisible(); + }); + + it("opens verification info modal", async () => { + const { openWith } = dynamicModalContextMock; + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText("How does verification work?")); + + expect(openWith).toHaveBeenCalledWith(); + }); + + it("calls handleVerify when Verify Now is clicked", async () => { + const mockHandleVerify = jest.fn(); + jest.mocked(useHandleVerify).mockReturnValue(mockHandleVerify); + + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText("Verify Now")); + + expect(mockHandleVerify).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/EmptyMessage/VerifyMessage.tsx b/apps/web/src/components/Onboarding/VerificationFlow/VerifyMessage.tsx similarity index 73% rename from apps/web/src/components/EmptyMessage/VerifyMessage.tsx rename to apps/web/src/components/Onboarding/VerificationFlow/VerifyMessage.tsx index e829f1f141..ed5cdc6fbd 100644 --- a/apps/web/src/components/EmptyMessage/VerifyMessage.tsx +++ b/apps/web/src/components/Onboarding/VerificationFlow/VerifyMessage.tsx @@ -1,18 +1,21 @@ import { Flex, Link } from "@chakra-ui/react"; import { useDynamicModalContext } from "@umami/components"; -import { EmptyMessage, type EmptyMessageProps } from "./EmptyMessage"; -import { useColor } from "../../styles/useColor"; -import { VerificationInfoModal } from "../Onboarding/VerificationInfoModal"; +import { useHandleVerify } from "./useHandleVerify"; +import { VerificationInfoModal } from "./VerificationInfoModal"; +import { useColor } from "../../../styles/useColor"; +import { EmptyMessage, type EmptyMessageProps } from "../../EmptyMessage"; export const VerifyMessage = ({ ...props }: Omit) => { - const { openWith } = useDynamicModalContext(); const color = useColor(); + const { openWith } = useDynamicModalContext(); + const handleVerify = useHandleVerify(); return ( ({ + ...jest.requireActual("@umami/core"), + selectRandomElements: jest.fn(), +})); + +const selectRandomElementsMock = jest.mocked(selectRandomElements); + +let store: UmamiStore; + +beforeEach(() => { + const splitted = mnemonic1.split(" ").map((value, index) => ({ + index, + value, + })); + selectRandomElementsMock.mockReturnValue(splitted.slice(0, 5)); + + store = makeStore(); + addTestAccount(store, mockMnemonicAccount(0, { isVerified: false })); +}); + +const fixture = () => ; + +describe("", () => { + test("when no mnemonic has been entered the button is disabled", async () => { + await renderInModal(fixture(), store); + + expect(screen.getByRole("button", { name: "Verify" })).toBeDisabled(); + }); + + test("when an invalid mnemonic has been entered the button is disabled", async () => { + const user = userEvent.setup(); + + await renderInModal(fixture(), store); + + const inputFields = screen.getAllByRole("textbox"); + + for (const input of inputFields) { + await act(() => user.type(input, "test")); + } + + expect(screen.getByRole("button", { name: "Verify" })).toBeDisabled(); + }); + + test("validation is working with all invalid", async () => { + await renderInModal(fixture(), store); + const inputFields = screen.getAllByRole("textbox"); + + inputFields.forEach(input => { + fireEvent.change(input, { target: { value: "test" } }); + fireEvent.blur(input); + }); + + await waitFor(() => { + expect(screen.getAllByText("Word doesn't match").length).toBe(5); + }); + }); + + test("validation is working with some invalid", async () => { + await renderInModal(fixture(), store); + const inputFields = screen.getAllByRole("textbox"); + + fireEvent.change(inputFields[0], { + target: { value: mnemonic1.split(" ")[0] }, + }); + fireEvent.blur(inputFields[0]); + + inputFields.forEach(input => { + fireEvent.change(input, { target: { value: "test" } }); + fireEvent.blur(input); + }); + await waitFor(() => { + expect(screen.getAllByText("Word doesn't match").length).toBe(5); + }); + }); + + test("validation is working with all valid", async () => { + await renderInModal(fixture(), store); + const inputFields = screen.getAllByRole("textbox"); + const splitted = mnemonic1.split(" "); + + inputFields.forEach((input, index) => { + fireEvent.change(input, { target: { value: splitted[index] } }); + fireEvent.blur(input); + }); + + const confirmBtn = screen.getByRole("button", { name: "Verify" }); + + await waitFor(() => { + expect(confirmBtn).toBeEnabled(); + }); + + fireEvent.click(confirmBtn); + + await waitFor(() => { + expect((store.getState().accounts.items[0] as MnemonicAccount).isVerified).toBe(true); + }); + }); +}); diff --git a/apps/web/src/components/Onboarding/VerificationFlow/VerifySeedphraseModal.tsx b/apps/web/src/components/Onboarding/VerificationFlow/VerifySeedphraseModal.tsx new file mode 100644 index 0000000000..4af70142d5 --- /dev/null +++ b/apps/web/src/components/Onboarding/VerificationFlow/VerifySeedphraseModal.tsx @@ -0,0 +1,128 @@ +import { + Button, + Center, + Flex, + FormControl, + FormErrorMessage, + Heading, + Icon, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + Text, +} from "@chakra-ui/react"; +import { useDynamicModalContext } from "@umami/components"; +import { selectRandomElements } from "@umami/core"; +import { accountsActions, useAppDispatch, useCurrentAccount } from "@umami/state"; +import { useState } from "react"; +import { FormProvider, useForm } from "react-hook-form"; + +import { KeyIcon } from "../../../assets/icons"; +import { IS_DEV } from "../../../env"; +import { useColor } from "../../../styles/useColor"; +import { ModalBackButton } from "../../BackButton"; +import { ModalCloseButton } from "../../CloseButton"; +import { MnemonicWord } from "../../MnemonicWord"; + +type VerifySeedphraseModalProps = { + seedPhrase: string; +}; + +export const VerifySeedphraseModal = ({ seedPhrase }: VerifySeedphraseModalProps) => { + const color = useColor(); + const dispatch = useAppDispatch(); + const currentAccount = useCurrentAccount()!; + const { onClose } = useDynamicModalContext(); + const form = useForm({ + mode: "onBlur", + defaultValues: { + word1: "", + word2: "", + word3: "", + }, + }); + const { + handleSubmit, + formState: { errors, isValid }, + } = form; + + const seedphraseArray = seedPhrase.split(" "); + const [randomElements] = useState(selectRandomElements(seedphraseArray, 3)); + + const onSubmit = () => { + dispatch( + accountsActions.setIsVerified({ + pkh: currentAccount.address.pkh, + isVerified: true, + }) + ); + + dispatch(accountsActions.setPassword("")); + + onClose(); + }; + + return ( + + + + +
+ + Verify Seed Phrase + + To verify, please type in the word that corresponds to each sequence number. + +
+
+ +
+ + + {randomElements.map(({ index, value }) => { + const inputName = `word${index + 1}`; + const error = errors[inputName as keyof typeof errors]; + + return ( + + { + if (_value !== value) { + return "Word doesn't match"; + } + }, + }} + index={index} + /> + {error?.message && {error.message}} + + ); + })} + + + + + + {IS_DEV && ( + + )} + +
+
+
+ ); +}; diff --git a/apps/web/src/components/Onboarding/VerificationFlow/index.ts b/apps/web/src/components/Onboarding/VerificationFlow/index.ts new file mode 100644 index 0000000000..55ea73f028 --- /dev/null +++ b/apps/web/src/components/Onboarding/VerificationFlow/index.ts @@ -0,0 +1,2 @@ +export { VerifyMessage } from "./VerifyMessage"; +export { useIsAccountVerified } from "./useIsAccountVerified"; diff --git a/apps/web/src/components/Onboarding/VerificationFlow/useHandleVerify.test.tsx b/apps/web/src/components/Onboarding/VerificationFlow/useHandleVerify.test.tsx new file mode 100644 index 0000000000..a89afca2e9 --- /dev/null +++ b/apps/web/src/components/Onboarding/VerificationFlow/useHandleVerify.test.tsx @@ -0,0 +1,52 @@ +import { mockMnemonicAccount } from "@umami/core"; +import { + type UmamiStore, + accountsActions, + addTestAccount, + makeStore, + useGetDecryptedMnemonic, +} from "@umami/state"; +import { mnemonic1 } from "@umami/test-utils"; + +import { ImportantNoticeModal } from "./ImportantNoticeModal"; +import { useHandleVerify } from "./useHandleVerify"; +import { act, dynamicModalContextMock, renderHook } from "../../../testUtils"; +import { SetupPassword } from "../SetupPassword"; + +jest.mock("@umami/state", () => ({ + ...jest.requireActual("@umami/state"), + useGetDecryptedMnemonic: jest.fn(), +})); + +let store: UmamiStore; + +beforeEach(() => { + store = makeStore(); + addTestAccount(store, mockMnemonicAccount(0, { isVerified: false })); +}); + +describe("useHandleVerify", () => { + it("should open SetupPassword modal if master password is not set", async () => { + const { openWith } = dynamicModalContextMock; + const { result } = renderHook(useHandleVerify, { store }); + + await act(() => result.current()); + + expect(openWith).toHaveBeenCalledWith(); + }); + + it("should open ImportantNoticeModal modal if master password is set", async () => { + jest.mocked(useGetDecryptedMnemonic).mockReturnValue(() => Promise.resolve(mnemonic1)); + + store.dispatch(accountsActions.setPassword("password")); + + const { openWith } = dynamicModalContextMock; + const { result } = renderHook(useHandleVerify, { store }); + + await act(() => result.current()); + + expect(openWith).toHaveBeenCalledWith(, { + size: "xl", + }); + }); +}); diff --git a/apps/web/src/components/Onboarding/VerificationFlow/useHandleVerify.tsx b/apps/web/src/components/Onboarding/VerificationFlow/useHandleVerify.tsx new file mode 100644 index 0000000000..8070f8630c --- /dev/null +++ b/apps/web/src/components/Onboarding/VerificationFlow/useHandleVerify.tsx @@ -0,0 +1,30 @@ +import { useDynamicModalContext } from "@umami/components"; +import { type MnemonicAccount } from "@umami/core"; +import { + useAppSelector, + useAsyncActionHandler, + useCurrentAccount, + useGetDecryptedMnemonic, +} from "@umami/state"; + +import { ImportantNoticeModal } from "./ImportantNoticeModal"; +import { SetupPassword } from "../SetupPassword"; + +export const useHandleVerify = () => { + const { openWith } = useDynamicModalContext(); + const currentAccount = useCurrentAccount() as MnemonicAccount; + const getDecryptedMnemonic = useGetDecryptedMnemonic(); + const { handleAsyncAction } = useAsyncActionHandler(); + const { password } = useAppSelector(state => state.accounts); + + return () => + handleAsyncAction(async () => { + if (password) { + const mnemonic = await getDecryptedMnemonic(currentAccount, password); + + return openWith(, { size: "xl" }); + } + + return openWith(); + }); +}; diff --git a/apps/web/src/components/Onboarding/VerificationFlow/useIsAccountVerified.test.tsx b/apps/web/src/components/Onboarding/VerificationFlow/useIsAccountVerified.test.tsx new file mode 100644 index 0000000000..61d2c3a141 --- /dev/null +++ b/apps/web/src/components/Onboarding/VerificationFlow/useIsAccountVerified.test.tsx @@ -0,0 +1,26 @@ +import { mockLedgerAccount, mockMnemonicAccount, mockSocialAccount } from "@umami/core"; +import { type UmamiStore, addTestAccount, makeStore } from "@umami/state"; + +import { useIsAccountVerified } from "./useIsAccountVerified"; +import { renderHook } from "../../../testUtils"; + +let store: UmamiStore; + +beforeEach(() => { + store = makeStore(); +}); + +describe("useIsAccountVerified", () => { + it.each([ + // verified status, account type, account + [true, "social", mockSocialAccount(0)], + [true, "ledger", mockLedgerAccount(0)], + [true, "verified mnemonic", mockMnemonicAccount(0)], + [false, "unverified mnemonic", mockMnemonicAccount(0, { isVerified: false })], + ])("returns %s for %s account", (isVerified, _, account) => { + addTestAccount(store, account); + const { result } = renderHook(() => useIsAccountVerified(), { store }); + + expect(result.current).toBe(isVerified); + }); +}); diff --git a/apps/web/src/components/Onboarding/useIsAccountVerified.tsx b/apps/web/src/components/Onboarding/VerificationFlow/useIsAccountVerified.tsx similarity index 100% rename from apps/web/src/components/Onboarding/useIsAccountVerified.tsx rename to apps/web/src/components/Onboarding/VerificationFlow/useIsAccountVerified.tsx diff --git a/apps/web/src/components/ViewOverlay/ViewOverlay.tsx b/apps/web/src/components/ViewOverlay/ViewOverlay.tsx index a82bbef558..3e710b1d5c 100644 --- a/apps/web/src/components/ViewOverlay/ViewOverlay.tsx +++ b/apps/web/src/components/ViewOverlay/ViewOverlay.tsx @@ -2,7 +2,7 @@ import { Box, Icon } from "@chakra-ui/react"; import { CoinIcon, LockIcon, PyramidIcon, WalletIcon } from "../../assets/icons"; import { useColor } from "../../styles/useColor"; -import { useIsAccountVerified } from "../Onboarding/useIsAccountVerified"; +import { useIsAccountVerified } from "../Onboarding/VerificationFlow"; type ViewOverlayProps = { iconType: "activity" | "earn" | "nfts" | "tokens"; diff --git a/apps/web/src/views/Activity/Activity.tsx b/apps/web/src/views/Activity/Activity.tsx index bc40faf298..67775407f4 100644 --- a/apps/web/src/views/Activity/Activity.tsx +++ b/apps/web/src/views/Activity/Activity.tsx @@ -6,8 +6,8 @@ import { type UIEvent, useRef } from "react"; import loadingDots from "../../assets/loading-dots.gif"; import loadingWheel from "../../assets/loading-wheel.gif"; -import { EmptyMessage, VerifyMessage } from "../../components/EmptyMessage"; -import { useIsAccountVerified } from "../../components/Onboarding/useIsAccountVerified"; +import { EmptyMessage } from "../../components/EmptyMessage"; +import { VerifyMessage, useIsAccountVerified } from "../../components/Onboarding/VerificationFlow"; import { OperationTile } from "../../components/OperationTile"; import { ViewOverlay } from "../../components/ViewOverlay/ViewOverlay"; import { useColor } from "../../styles/useColor"; diff --git a/apps/web/src/views/NFTs/NFTs.tsx b/apps/web/src/views/NFTs/NFTs.tsx index 12d48d805d..94ece3c4bc 100644 --- a/apps/web/src/views/NFTs/NFTs.tsx +++ b/apps/web/src/views/NFTs/NFTs.tsx @@ -7,8 +7,8 @@ import { range } from "lodash"; import { NFTCard } from "./NFTCard"; import { NFTDrawer } from "./NFTDrawer"; import { NFTFilter, useNFTFilter } from "./NFTFilter"; -import { EmptyMessage, VerifyMessage } from "../../components/EmptyMessage"; -import { useIsAccountVerified } from "../../components/Onboarding/useIsAccountVerified"; +import { EmptyMessage } from "../../components/EmptyMessage"; +import { VerifyMessage, useIsAccountVerified } from "../../components/Onboarding/VerificationFlow"; import { ViewOverlay } from "../../components/ViewOverlay/ViewOverlay"; import { useColor } from "../../styles/useColor"; diff --git a/apps/web/src/views/Tokens/Tokens.tsx b/apps/web/src/views/Tokens/Tokens.tsx index b3c0e854c8..3baa203218 100644 --- a/apps/web/src/views/Tokens/Tokens.tsx +++ b/apps/web/src/views/Tokens/Tokens.tsx @@ -3,8 +3,9 @@ import { fullId } from "@umami/core"; import { useCurrentAccount, useGetAccountAllTokens } from "@umami/state"; import { Token } from "./Token"; -import { EmptyMessage, VerifyMessage } from "../../components/EmptyMessage"; -import { useIsAccountVerified } from "../../components/Onboarding/useIsAccountVerified"; +import { EmptyMessage } from "../../components/EmptyMessage"; +import { useIsAccountVerified } from "../../components/Onboarding/VerificationFlow"; +import { VerifyMessage } from "../../components/Onboarding/VerificationFlow/VerifyMessage"; import { ViewOverlay } from "../../components/ViewOverlay/ViewOverlay"; export const Tokens = () => { diff --git a/packages/state/src/hooks/getAccountData.ts b/packages/state/src/hooks/getAccountData.ts index c7f2f083bc..899fde7f67 100644 --- a/packages/state/src/hooks/getAccountData.ts +++ b/packages/state/src/hooks/getAccountData.ts @@ -165,8 +165,8 @@ export const useGetMostFundedImplicitAccount = () => { }; export const useGetSecretKey = () => { - const seedPhrases = useSeedPhrases(); const encryptedSecretKeys = useSecretKeys(); + const getDecryptedMnemonic = useGetDecryptedMnemonic(); return async (account: MnemonicAccount | SecretKeyAccount, password: string) => { if (account.type === "secret_key") { @@ -178,12 +178,7 @@ export const useGetSecretKey = () => { return decrypt(encryptedSecretKey, password); } - const encryptedMnemonic = seedPhrases[account.seedFingerPrint]; - if (!encryptedMnemonic) { - throw new Error(`Missing seedphrase for account ${account.address.pkh}`); - } - - const mnemonic = await decrypt(encryptedMnemonic, password); + const mnemonic = await getDecryptedMnemonic(account, password); return deriveSecretKey(mnemonic, account.derivationPath, account.curve); }; }; @@ -205,3 +200,17 @@ export const useCurrentAccount = (): ImplicitAccount | undefined => { return currentAccount; }; + +export const useGetDecryptedMnemonic = () => { + const seedPhrases = useSeedPhrases(); + + return async (account: MnemonicAccount, password: string) => { + const encryptedMnemonic = seedPhrases[account.seedFingerPrint]; + + if (!encryptedMnemonic) { + throw new Error(`Missing seedphrase for account ${account.address.pkh}`); + } + + return decrypt(encryptedMnemonic, password); + }; +}; diff --git a/packages/state/src/reducer.ts b/packages/state/src/reducer.ts index 716c76ee2d..c1e376bcf4 100644 --- a/packages/state/src/reducer.ts +++ b/packages/state/src/reducer.ts @@ -29,6 +29,7 @@ const accountsPersistConfig = { version: VERSION, storage, migrate: createAsyncMigrate(accountsMigrations, { debug: false }), + blacklist: ["password"], }; const rootReducers = combineReducers({ diff --git a/packages/state/src/slices/accounts/State.ts b/packages/state/src/slices/accounts/State.ts index 427edb7dc5..bd971b7ac9 100644 --- a/packages/state/src/slices/accounts/State.ts +++ b/packages/state/src/slices/accounts/State.ts @@ -8,4 +8,5 @@ export type AccountsState = { seedPhrases: Record; secretKeys: Record; current?: RawPkh | undefined; + password?: string | undefined; }; diff --git a/packages/state/src/slices/accounts/accounts.test.ts b/packages/state/src/slices/accounts/accounts.test.ts index d9a4989629..b73e9a57ea 100644 --- a/packages/state/src/slices/accounts/accounts.test.ts +++ b/packages/state/src/slices/accounts/accounts.test.ts @@ -23,6 +23,7 @@ describe("accountsSlice", () => { items: [], seedPhrases: {}, secretKeys: {}, + password: "", }); }); @@ -55,6 +56,7 @@ describe("accountsSlice", () => { ], seedPhrases: { mockPrint1: {}, mockPrint2: {} }, secretKeys: {}, + password: "", }); store.dispatch(accountsActions.removeMnemonicAndAccounts({ fingerPrint: "mockPrint1" })); @@ -63,6 +65,7 @@ describe("accountsSlice", () => { items: [mockImplicitAccount(2, undefined, "mockPrint2")], seedPhrases: { mockPrint2: {} }, secretKeys: {}, + password: "", }); }); }); diff --git a/packages/state/src/slices/accounts/accounts.ts b/packages/state/src/slices/accounts/accounts.ts index 0550e6b38a..7445f68f6e 100644 --- a/packages/state/src/slices/accounts/accounts.ts +++ b/packages/state/src/slices/accounts/accounts.ts @@ -18,6 +18,7 @@ export const accountsInitialState: AccountsState = { items: [], seedPhrases: {}, secretKeys: {}, + password: "", }; /** @@ -139,6 +140,9 @@ export const accountsSlice = createSlice({ account.isVerified = isVerified; }, + setPassword: (state, { payload }: { payload: string }) => { + state.password = payload; + }, }, });