Skip to content

Commit

Permalink
Onboarding import account via mnemonic (#32)
Browse files Browse the repository at this point in the history
* Mnemonic phrase import

* Import styling

* Break out mnemonic into its own component
  • Loading branch information
dgca authored Nov 10, 2023
1 parent 84770d6 commit c0afb4d
Show file tree
Hide file tree
Showing 10 changed files with 230 additions and 36 deletions.
32 changes: 32 additions & 0 deletions main/api/accounts/handleImportAccount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import log from "electron-log";
import * as z from "zod";

import { manager } from "../manager";

export const handleImportAccountInputs = z.object({
name: z.string().optional(),
account: z.string(),
});

export async function handleImportAccount({
name,
account,
}: z.infer<typeof handleImportAccountInputs>) {
const ironfish = await manager.getIronfish();
const rpcClient = await ironfish.rpcClient();

try {
const importResponse = await rpcClient.wallet.importAccount({
name,
account,
});

return importResponse.content;
} catch (error: unknown) {
log.error(error);

throw new Error(
"Failed to import account, please verify your inputs and try again.",
);
}
}
9 changes: 9 additions & 0 deletions main/api/accounts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import {
} from "./handleExportAccount";
import { handleGetAccount } from "./handleGetAccount";
import { handleGetAccounts } from "./handleGetAccounts";
import {
handleImportAccount,
handleImportAccountInputs,
} from "./handleImportAccount";
import {
handleRenameAccountInputs,
handleRenameAccount,
Expand Down Expand Up @@ -34,6 +38,11 @@ export const accountRouter = t.router({
.mutation(async (opts) => {
return handleCreateAccount(opts.input);
}),
importAccount: t.procedure
.input(handleImportAccountInputs)
.mutation(async (opts) => {
return handleImportAccount(opts.input);
}),
exportAccount: t.procedure
.input(handleExportAccountInputs)
.query(async (opts) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { useState, useMemo } from "react";
import {
EMPTY_PHRASE_ARRAY,
MnemonicPhrase,
PHRASE_ITEM_COUNT,
} from "@/ui/Forms/MnemonicPhrase/MnemonicPhrase";
import { PillButton } from "@/ui/PillButton/PillButton";
import { validateMnemonic } from "@/utils/mnemonic";

export function ConfirmAccountStep({
mnemonicPhrase,
Expand All @@ -24,20 +24,8 @@ export function ConfirmAccountStep({
useState<Array<string>>(EMPTY_PHRASE_ARRAY);

const errorMessage = useMemo(() => {
const valueString = confirmValues.join(" ").replace(/\s+/, " ");
const hasCorrectLength =
valueString.match(/\s/g)?.length === PHRASE_ITEM_COUNT - 1;

if (!hasCorrectLength) {
return "Please fill out the entire phrase.";
}

if (mnemonicPhrase !== valueString) {
return "The phrase you entered does not match. Please verify the phrase and try again.";
}

return undefined;
}, [confirmValues, mnemonicPhrase]);
return validateMnemonic(confirmValues);
}, [confirmValues]);

const isValid = mnemonicPhrase === confirmValues.join(" ");

Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,43 @@
import { Box, Heading } from "@chakra-ui/react";
import Link from "next/link";
import { useRouter } from "next/router";

import { trpcReact } from "@/providers/TRPCProvider";

import { MnemonicImport } from "./MnemonicImport";

export function ImportAccount() {
const router = useRouter();

const {
mutate: importAccount,
isLoading,
error,
} = trpcReact.importAccount.useMutation();

return (
<Box>
<Link href="/onboarding">Back</Link>
<Heading mt={24} mb={8}>
Import Account WIP
Import Account
</Heading>
<MnemonicImport
isLoading={isLoading}
error={error?.shape?.message}
handleImport={({ name, account }) => {
importAccount(
{
name,
account,
},
{
onSuccess: () => {
router.push("/onboarding/snapshot-download");
},
},
);
}}
/>
</Box>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { Box, Text } from "@chakra-ui/react";
import { useMemo, useState } from "react";

import {
EMPTY_PHRASE_ARRAY,
MnemonicPhrase,
} from "@/ui/Forms/MnemonicPhrase/MnemonicPhrase";
import { TextInput } from "@/ui/Forms/TextInput/TextInput";
import { PillButton } from "@/ui/PillButton/PillButton";
import { validateMnemonic } from "@/utils/mnemonic";

type Props = {
handleImport: (args: { name: string; account: string }) => void;
isLoading: boolean;
error?: string | null;
};

export function MnemonicImport({ handleImport, isLoading, error }: Props) {
const [accountName, setAccountName] = useState("");
const [isAccountNameDirty, setIsAccountNameDirty] = useState(false);
const [phraseValues, setPhraseValues] =
useState<Array<string>>(EMPTY_PHRASE_ARRAY);

const errorMessage = useMemo(() => {
return validateMnemonic(phraseValues);
}, [phraseValues]);

const hasValidName = accountName.length > 0;
const hasNameError = isAccountNameDirty && !hasValidName;

return (
<Box>
<TextInput
label="Account Name"
value={accountName}
error={
isAccountNameDirty && !hasValidName
? "Please enter a name for this account"
: null
}
onChange={(e) => {
setAccountName(e.target.value);
}}
onBlur={() => {
setIsAccountNameDirty(true);
}}
/>
<Text mt={8} mb={4}>
Please enter your mnemonic phrase. If you&apos;ve copied the full phrase
to your clipboard, you can paste it in any input and it will
automatically be split into the correct number of words.
</Text>
<MnemonicPhrase
defaultVisible
phrase={phraseValues}
error={errorMessage || error}
onChange={(value) => {
setPhraseValues(value);
}}
mb={8}
/>
<PillButton
height="60px"
px={8}
isDisabled={isLoading || !!errorMessage || hasNameError}
onClick={() => {
if (!hasValidName) {
setIsAccountNameDirty(true);
return;
}

handleImport({
name: accountName,
account: phraseValues.join(" "),
});
}}
>
Continue
</PillButton>
</Box>
);
}
24 changes: 15 additions & 9 deletions renderer/ui/Forms/FormField/FormField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,18 @@ import { ReactNode, useMemo } from "react";
import { FieldError, FieldErrorsImpl } from "react-hook-form";

import { COLORS } from "@/ui/colors";
import { MergeProps } from "@/utils/react";

export type FormFieldProps = {
label: string | ReactNode;
error?: string | FieldError | FieldErrorsImpl;
icon?: ReactNode;
triggerProps?: StackProps & { ref: unknown };
actions?: ReactNode;
};
export type FormFieldProps = MergeProps<
{
label: string | ReactNode;
error?: string | FieldError | FieldErrorsImpl | null;
icon?: ReactNode;
triggerProps?: StackProps & { ref: unknown };
actions?: ReactNode;
},
StackProps
>;

export function FormField({
children,
Expand All @@ -19,11 +23,12 @@ export function FormField({
icon,
triggerProps,
actions,
...rest
}: FormFieldProps & {
children: ReactNode;
}) {
return (
<VStack>
<VStack {...rest}>
<HStack
as="label"
w="100%"
Expand Down Expand Up @@ -61,13 +66,14 @@ export function FormField({
function RenderError({
error,
}: {
error?: string | FieldError | FieldErrorsImpl;
error?: string | FieldError | FieldErrorsImpl | null;
}) {
const message = useMemo(() => {
if (typeof error === "string") {
return error;
}
return typeof error === "object" &&
error !== null &&
"message" in error &&
typeof error.message === "string"
? error.message
Expand Down
29 changes: 19 additions & 10 deletions renderer/ui/Forms/MnemonicPhrase/MnemonicPhrase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,29 +17,34 @@ import { useCopyToClipboard, useToggle } from "usehooks-ts";

import { COLORS } from "@/ui/colors";
import { useHasGroupBlur } from "@/utils/formUtils";
import { MergeProps } from "@/utils/react";

import { FormField } from "../FormField/FormField";
import { FormField, FormFieldProps } from "../FormField/FormField";

export const PHRASE_ITEM_COUNT = 24;
export const EMPTY_PHRASE_ARRAY = Array.from(
{ length: PHRASE_ITEM_COUNT },
() => "",
);

type Props = {
phrase: Array<string>;
readOnly?: boolean;
onChange?: (phrase: Array<string>) => void;
defaultVisible?: boolean;
error?: string;
};
type Props = MergeProps<
{
phrase: Array<string>;
readOnly?: boolean;
onChange?: (phrase: Array<string>) => void;
defaultVisible?: boolean;
error?: string | null;
},
Omit<FormFieldProps, "label">
>;

export function MnemonicPhrase({
phrase,
readOnly,
onChange,
defaultVisible,
error,
...rest
}: Props) {
const { hasBlur, handleGroupFocus, handleGroupBlur } = useHasGroupBlur();
const [isHidden, toggleIsHidden] = useToggle(defaultVisible ? false : true);
Expand Down Expand Up @@ -70,15 +75,18 @@ export function MnemonicPhrase({
e.preventDefault();

const number = get(e, "target.dataset.number") as unknown;

if (typeof number !== "string") {
throw new Error("data-number not found in mnemonic phrase input");
}

const words = e.clipboardData.getData("text").split(/\s+/g);
const words = e.clipboardData.getData("text").trim().split(/\s+/g);
const index = parseInt(number, 10) - 1;

if (words.length === PHRASE_ITEM_COUNT) {
console.log("match", words.length, PHRASE_ITEM_COUNT);
onChange(words);
return;
}

const nextValues = phrase
Expand All @@ -92,7 +100,8 @@ export function MnemonicPhrase({

return (
<FormField
error={hasBlur ? error : undefined}
{...rest}
error={hasBlur && error ? error : undefined}
label={
<HStack flexGrow={1}>
<Text fontSize="sm" color={COLORS.GRAY_MEDIUM}>
Expand Down
3 changes: 2 additions & 1 deletion renderer/ui/Forms/TextInput/TextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { forwardRef } from "react";

import { FormField, FormFieldProps } from "../FormField/FormField";

type Props = FormFieldProps & InputProps;
type Props = Pick<FormFieldProps, "label" | "error" | "icon" | "triggerProps"> &
InputProps;

export const TextInput = forwardRef<HTMLInputElement, Props>(function TextInput(
{ label, error, icon, triggerProps, ...rest },
Expand Down
36 changes: 36 additions & 0 deletions renderer/utils/mnemonic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export const MNEMONIC_ITEM_COUNT = 24;
export const EMPTY_MNEMONIC_ARRAY = Array.from(
{ length: MNEMONIC_ITEM_COUNT },
() => "",
);

export function formatMnemonic(phrase: string | Array<string>) {
const asString = Array.isArray(phrase) ? phrase.join(" ") : phrase;
return asString.trim().replace(/\s+/g, " ");
}

export function validateMnemonic(
phrase: string | Array<string>,
comparePhrase?: string,
) {
const formatted = formatMnemonic(phrase);

const hasCorrectLength =
formatted.match(/\s/g)?.length === MNEMONIC_ITEM_COUNT - 1;

if (!hasCorrectLength) {
return "Please fill out the entire phrase.";
}

if (typeof comparePhrase === "string" && !validateMnemonic(comparePhrase)) {
throw new Error(
"Invalid compare phrase. Please ensure compare phrase is a 24 word mnemonic phrase.",
);
}

if (typeof comparePhrase === "string" && formatted !== comparePhrase) {
return "The phrase you entered does not match. Please verify the phrase and try again.";
}

return null;
}
1 change: 1 addition & 0 deletions renderer/utils/react.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type MergeProps<T, U> = T & Omit<U, keyof T>;

0 comments on commit c0afb4d

Please sign in to comment.