1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-10-22 03:12:42 +02:00

Upgrading the Import Account modal (#17763)

Co-authored-by: georgewrmarshall <george.marshall@consensys.net>
Co-authored-by: NidhiKJha <nidhi.kumari@consensys.net>
Co-authored-by: montelaidev <monte.lai@consensys.net>
This commit is contained in:
HowardBraham 2023-03-06 23:18:28 +05:30 committed by GitHub
parent 01abfb3ec8
commit 694773f17a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 648 additions and 743 deletions

View File

@ -1640,8 +1640,23 @@
"importAccountError": {
"message": "Error importing account."
},
"importAccountErrorIsSRP": {
"message": "You have entered a Secret Recovery Phrase (or mnemonic). To import an account here, you have to enter a private key, which is a hexadecimal string of length 64."
},
"importAccountErrorNotAValidPrivateKey": {
"message": "This is not a valid private key. You have entered a hexadecimal string, but it must be 64 characters long."
},
"importAccountErrorNotHexadecimal": {
"message": "This is not a valid private key. You must enter a hexadecimal string of length 64."
},
"importAccountJsonLoading1": {
"message": "Expect this JSON import to take a few minutes and freeze MetaMask."
},
"importAccountJsonLoading2": {
"message": "We apologize, and we will make it faster in the future."
},
"importAccountMsg": {
"message": "Imported accounts will not be associated with your originally created MetaMask account Secret Recovery Phrase. Learn more about imported accounts"
"message": "Imported accounts wont be associated with your MetaMask Secret Recovery Phrase. Learn more about imported accounts"
},
"importMyWallet": {
"message": "Import my wallet"

View File

@ -1,9 +1,15 @@
import log from 'loglevel';
import { isValidMnemonic } from '@ethersproject/hdnode';
import {
bufferToHex,
getBinarySize,
isValidPrivate,
toBuffer,
} from 'ethereumjs-util';
import Wallet from 'ethereumjs-wallet';
import importers from 'ethereumjs-wallet/thirdparty';
import { toBuffer, isValidPrivate, bufferToHex } from 'ethereumjs-util';
import { addHexPrefix } from '../lib/util';
import log from 'loglevel';
import { stripHexPrefix } from '../../../shared/modules/hexstring-utils';
import { addHexPrefix } from '../lib/util';
const accountImporter = {
async importAccount(strategy, args) {
@ -15,18 +21,37 @@ const accountImporter = {
strategies: {
'Private Key': (privateKey) => {
if (!privateKey) {
throw new Error('Cannot import an empty key.');
throw new Error('Cannot import an empty key.'); // It should never get here, because this should be stopped in the UI
}
const prefixed = addHexPrefix(privateKey);
const buffer = toBuffer(prefixed);
if (!isValidPrivate(buffer)) {
throw new Error('Cannot import invalid private key.');
// Check if the user has entered an SRP by mistake instead of a private key
if (isValidMnemonic(privateKey.trim())) {
throw new Error(`t('importAccountErrorIsSRP')`);
}
const stripped = stripHexPrefix(prefixed);
return stripped;
const trimmedPrivateKey = privateKey.replace(/\s+/gu, ''); // Remove all whitespace
const prefixedPrivateKey = addHexPrefix(trimmedPrivateKey);
let buffer;
try {
buffer = toBuffer(prefixedPrivateKey);
} catch (e) {
throw new Error(`t('importAccountErrorNotHexadecimal')`);
}
try {
if (
!isValidPrivate(buffer) ||
getBinarySize(prefixedPrivateKey) !== 64 + '0x'.length // Fixes issue #17719 -- isValidPrivate() will let a key of 63 hex digits through without complaining, this line ensures 64 hex digits + '0x' = 66 digits
) {
throw new Error(`t('importAccountErrorNotAValidPrivateKey')`);
}
} catch (e) {
throw new Error(`t('importAccountErrorNotAValidPrivateKey')`);
}
const strippedPrivateKey = stripHexPrefix(prefixedPrivateKey);
return strippedPrivateKey;
},
'JSON File': (input, password) => {
let wallet;

View File

@ -1,4 +1,3 @@
import { strict as assert } from 'assert';
import EventEmitter from 'events';
import { SINGLE_CALL_BALANCES_ADDRESS } from '../constants/contracts';
@ -94,7 +93,7 @@ describe('Account Tracker', () => {
currentBlockGasLimit: '',
};
assert.deepEqual(newAccounts, expectedAccounts);
expect(newAccounts).toStrictEqual(expectedAccounts);
});
it('should not change accounts if the passed address is not in accounts', async () => {
@ -118,7 +117,7 @@ describe('Account Tracker', () => {
currentBlockGasLimit: '',
};
assert.deepEqual(newAccounts, expectedAccounts);
expect(newAccounts).toStrictEqual(expectedAccounts);
});
it('should update the passed address account balance, and set other balances to null, if useMultiAccountBalanceChecker is false', async () => {
@ -137,7 +136,7 @@ describe('Account Tracker', () => {
currentBlockGasLimit: '',
};
assert.deepEqual(newAccounts, expectedAccounts);
expect(newAccounts).toStrictEqual(expectedAccounts);
});
});
@ -164,7 +163,7 @@ describe('Account Tracker', () => {
currentBlockGasLimit: '',
};
assert.deepEqual(newAccounts, expectedAccounts);
expect(newAccounts).toStrictEqual(expectedAccounts);
});
it('should update all balances if useMultiAccountBalanceChecker is true', async () => {
@ -192,7 +191,7 @@ describe('Account Tracker', () => {
currentBlockGasLimit: '',
};
assert.deepEqual(newAccounts, expectedAccounts);
expect(newAccounts).toStrictEqual(expectedAccounts);
});
});
});

View File

@ -242,7 +242,7 @@ describe('Add account', function () {
// enter private key',
await driver.fill('#private-key-box', testPrivateKey);
await driver.clickElement({ text: 'Import', tag: 'button' });
await driver.clickElement({ text: 'Import', tag: 'span' });
// should show the correct account name
const importedAccountName = await driver.findElement(

View File

@ -212,7 +212,7 @@ describe('MetaMask Import UI', function () {
// enter private key',
await driver.fill('#private-key-box', testPrivateKey1);
await driver.clickElement({ text: 'Import', tag: 'button' });
await driver.clickElement({ text: 'Import', tag: 'span' });
// should show the correct account name
const importedAccountName = await driver.findElement(
@ -239,7 +239,7 @@ describe('MetaMask Import UI', function () {
await driver.clickElement({ text: 'Import account', tag: 'div' });
// enter private key
await driver.fill('#private-key-box', testPrivateKey2);
await driver.clickElement({ text: 'Import', tag: 'button' });
await driver.clickElement({ text: 'Import', tag: 'span' });
// should see new account in account menu
const importedAccount2Name = await driver.findElement(
@ -315,7 +315,7 @@ describe('MetaMask Import UI', function () {
await driver.clickElement('.account-menu__icon');
await driver.clickElement({ text: 'Import account', tag: 'div' });
await driver.clickElement('.new-account-import-form__select');
await driver.clickElement('.dropdown__select');
await driver.clickElement({ text: 'JSON File', tag: 'option' });
const fileInput = await driver.findElement('input[type="file"]');
@ -330,7 +330,7 @@ describe('MetaMask Import UI', function () {
await driver.fill('#json-password-box', 'foobarbazqux');
await driver.clickElement({ text: 'Import', tag: 'button' });
await driver.clickElement({ text: 'Import', tag: 'span' });
// should show the correct account name
const importedAccountName = await driver.findElement(
@ -392,11 +392,11 @@ describe('MetaMask Import UI', function () {
// enter private key',
await driver.fill('#private-key-box', testPrivateKey);
await driver.clickElement({ text: 'Import', tag: 'button' });
await driver.clickElement({ text: 'Import', tag: 'span' });
// error should occur
await driver.waitForSelector({
css: '.error',
css: '.mm-help-text',
text: 'The account you are trying to import is a duplicate',
});
},

View File

@ -144,7 +144,7 @@ const AssetListItem = ({
name={ICON_NAMES.ARROW_RIGHT}
color={Color.iconDefault}
size={ICON_SIZES.SM}
style={{ 'vertical-align': 'middle' }}
style={{ verticalAlign: 'middle' }}
/>
{sendTokenButton}
</Box>

View File

@ -103,7 +103,6 @@ export const FormTextField = ({
/>
{helpText && (
<HelpText
error={error}
severity={error && SEVERITIES.DANGER}
marginTop={1}
{...helpTextProps}

View File

@ -47,7 +47,7 @@ export const HelpText = ({
};
HelpText.propTypes = {
/**
* The color of the HelpText will be overridden if error is true
* The color of the HelpText will be overridden if there is a severity passed
* Defaults to Color.textDefault
*/
color: PropTypes.oneOf(Object.values(TextColor)),

View File

@ -26,7 +26,7 @@ export { Label } from './label';
export { PickerNetwork } from './picker-network';
export { Tag } from './tag';
export { TagUrl } from './tag-url';
export { Text, TEXT_DIRECTIONS } from './text';
export { Text, TEXT_DIRECTIONS, INVISIBLE_CHARACTER } from './text';
export { Input, INPUT_TYPES } from './input';
export { TextField, TEXT_FIELD_TYPES, TEXT_FIELD_SIZES } from './text-field';
export { TextFieldSearch } from './text-field-search';

View File

@ -1,2 +1,2 @@
export { Text } from './text';
export { TEXT_DIRECTIONS } from './text.constants';
export { TEXT_DIRECTIONS, INVISIBLE_CHARACTER } from './text.constants';

View File

@ -3,3 +3,9 @@ export const TEXT_DIRECTIONS = {
RIGHT_TO_LEFT: 'rtl',
AUTO: 'auto',
};
/**
* The INVISIBLE_CHARACTER is a very useful tool if you want to make sure a line of text
* takes up vertical space even if it's empty.
*/
export const INVISIBLE_CHARACTER = '\u200B';

View File

@ -1,3 +1,5 @@
import { INVISIBLE_CHARACTER } from '../../components/component-library';
export function getAccountNameErrorMessage(
accounts,
context,
@ -26,7 +28,7 @@ export function getAccountNameErrorMessage(
let errorMessage;
if (isValidAccountName) {
errorMessage = '\u200d'; // This is Unicode for an invisible character, so the spacing stays constant
errorMessage = INVISIBLE_CHARACTER; // Using an invisible character, so the spacing stays constant
} else if (isDuplicateAccountName) {
errorMessage = context.t('accountNameDuplicate');
} else if (isReservedAccountName) {

View File

@ -1,39 +1,36 @@
import React, { Component } from 'react';
import { Switch, Route } from 'react-router-dom';
import React from 'react';
import { Route, Switch } from 'react-router-dom';
import Box from '../../components/ui/box';
import {
NEW_ACCOUNT_ROUTE,
IMPORT_ACCOUNT_ROUTE,
CONNECT_HARDWARE_ROUTE,
IMPORT_ACCOUNT_ROUTE,
NEW_ACCOUNT_ROUTE,
} from '../../helpers/constants/routes';
import NewAccountCreateForm from './new-account.container';
import NewAccountImportForm from './import-account';
import ConnectHardwareForm from './connect-hardware';
import NewAccountImportForm from './import-account';
import NewAccountCreateForm from './new-account.container';
export default class CreateAccountPage extends Component {
render() {
return (
<div className="new-account">
<div className="new-account__form">
<Switch>
<Route
exact
path={NEW_ACCOUNT_ROUTE}
component={NewAccountCreateForm}
/>
<Route
exact
path={IMPORT_ACCOUNT_ROUTE}
component={NewAccountImportForm}
/>
<Route
exact
path={CONNECT_HARDWARE_ROUTE}
component={ConnectHardwareForm}
/>
</Switch>
</div>
</div>
);
}
export default function CreateAccountPage() {
return (
<Box className="new-account">
<Switch>
<Route
exact
path={NEW_ACCOUNT_ROUTE}
component={NewAccountCreateForm}
/>
<Route
exact
path={IMPORT_ACCOUNT_ROUTE}
component={NewAccountImportForm}
/>
<Route
exact
path={CONNECT_HARDWARE_ROUTE}
component={ConnectHardwareForm}
/>
</Switch>
</Box>
);
}

View File

@ -0,0 +1,48 @@
import PropTypes from 'prop-types';
import React from 'react';
import { useDispatch } from 'react-redux';
import {
ButtonPrimary,
ButtonSecondary,
BUTTON_SECONDARY_SIZES,
} from '../../../components/component-library';
import Box from '../../../components/ui/box/box';
import { DISPLAY } from '../../../helpers/constants/design-system';
import { useI18nContext } from '../../../hooks/useI18nContext';
import * as actions from '../../../store/actions';
BottomButtons.propTypes = {
importAccountFunc: PropTypes.func.isRequired,
isPrimaryDisabled: PropTypes.bool.isRequired,
};
export default function BottomButtons({
importAccountFunc,
isPrimaryDisabled,
}) {
const t = useI18nContext();
const dispatch = useDispatch();
return (
<Box display={DISPLAY.FLEX} gap={4}>
<ButtonSecondary
onClick={() => {
dispatch(actions.hideWarning());
window.history.back();
}}
size={BUTTON_SECONDARY_SIZES.LG}
block
>
{t('cancel')}
</ButtonSecondary>
<ButtonPrimary
onClick={importAccountFunc}
disabled={isPrimaryDisabled}
size={BUTTON_SECONDARY_SIZES.LG}
block
>
{t('import')}
</ButtonPrimary>
</Box>
);
}

View File

@ -0,0 +1,18 @@
import React from 'react';
import BottomButtons from './bottom-buttons';
export default {
title: 'Pages/CreateAccount/ImportAccount/BottomButtons',
component: BottomButtons,
argTypes: {
isPrimaryDisabled: {
control: {
type: 'boolean',
},
},
},
};
export const DefaultStory = (args) => <BottomButtons {...args} />;
DefaultStory.storyName = 'Default';

View File

@ -0,0 +1,159 @@
import React, { useContext, useState } from 'react';
import { useDispatch } from 'react-redux';
import { EVENT, EVENT_NAMES } from '../../../../shared/constants/metametrics';
import { ButtonLink, Label, Text } from '../../../components/component-library';
import Box from '../../../components/ui/box';
import Dropdown from '../../../components/ui/dropdown';
import { MetaMetricsContext } from '../../../contexts/metametrics';
import {
BLOCK_SIZES,
BorderColor,
FONT_WEIGHT,
JustifyContent,
Size,
TextVariant,
} from '../../../helpers/constants/design-system';
import ZENDESK_URLS from '../../../helpers/constants/zendesk-url';
import { useI18nContext } from '../../../hooks/useI18nContext';
import { useRouting } from '../../../hooks/useRouting';
import * as actions from '../../../store/actions';
// Subviews
import JsonImportView from './json';
import PrivateKeyImportView from './private-key';
export default function NewAccountImportForm() {
const t = useI18nContext();
const dispatch = useDispatch();
const trackEvent = useContext(MetaMetricsContext);
const { navigateToMostRecentOverviewPage } = useRouting();
const menuItems = [t('privateKey'), t('jsonFile')];
const [type, setType] = useState(menuItems[0]);
function importAccount(strategy, importArgs) {
const loadingMessage = getLoadingMessage(strategy);
dispatch(actions.importNewAccount(strategy, importArgs, loadingMessage))
.then(({ selectedAddress }) => {
if (selectedAddress) {
trackImportEvent(strategy, true);
dispatch(actions.hideWarning());
navigateToMostRecentOverviewPage();
} else {
dispatch(actions.displayWarning(t('importAccountError')));
trackImportEvent(strategy, false);
}
})
.catch((error) => translateWarning(error.message));
}
function trackImportEvent(strategy, wasSuccessful) {
const accountImportType =
strategy === 'Private Key'
? EVENT.ACCOUNT_IMPORT_TYPES.PRIVATE_KEY
: EVENT.ACCOUNT_IMPORT_TYPES.JSON;
const event = wasSuccessful
? EVENT_NAMES.ACCOUNT_ADDED
: EVENT_NAMES.ACCOUNT_ADD_FAILED;
trackEvent({
category: EVENT.CATEGORIES.ACCOUNTS,
event,
properties: {
account_type: EVENT.ACCOUNT_TYPES.IMPORTED,
account_import_type: accountImportType,
},
});
}
function getLoadingMessage(strategy) {
if (strategy === 'JSON File') {
return (
<Text width={BLOCK_SIZES.THREE_FOURTHS} fontWeight={FONT_WEIGHT.BOLD}>
<br />
{t('importAccountJsonLoading1')}
<br />
<br />
{t('importAccountJsonLoading2')}
</Text>
);
}
return '';
}
/**
* @param {string} message - an Error/Warning message caught in importAccount()
* This function receives a message that is a string like:
* `t('importAccountErrorNotHexadecimal')`
* `t('importAccountErrorIsSRP')`
* `t('importAccountErrorNotAValidPrivateKey')`
* and feeds it through useI18nContext
*/
function translateWarning(message) {
if (message && !message.startsWith('t(')) {
// This is just a normal error message
dispatch(actions.displayWarning(message));
} else {
// This is an error message in a form like
// `t('importAccountErrorNotHexadecimal')`
// so slice off the first 3 chars and last 2 chars, and feed to i18n
dispatch(actions.displayWarning(t(message.slice(3, -2))));
}
}
function PrivateKeyOrJson() {
switch (type) {
case menuItems[0]:
return <PrivateKeyImportView importAccountFunc={importAccount} />;
case menuItems[1]:
default:
return <JsonImportView importAccountFunc={importAccount} />;
}
}
return (
<>
<Box
padding={4}
className="bottom-border-1px" // There is no way to do just a bottom border in the Design System
borderColor={BorderColor.borderDefault}
>
<Text variant={TextVariant.headingLg}>{t('importAccount')}</Text>
<Text variant={TextVariant.bodySm} marginTop={2}>
{t('importAccountMsg')}{' '}
<ButtonLink
size={Size.inherit}
href={ZENDESK_URLS.IMPORTED_ACCOUNTS}
target="_blank"
rel="noopener noreferrer"
>
{t('here')}
</ButtonLink>
</Text>
</Box>
<Box padding={4} paddingBottom={8} paddingLeft={4} paddingRight={4}>
<Label
width={BLOCK_SIZES.FULL}
marginBottom={4}
justifyContent={JustifyContent.spaceBetween}
>
{t('selectType')}
<Dropdown
options={menuItems.map((text) => ({ value: text }))}
selectedOption={type}
onChange={(value) => {
dispatch(actions.hideWarning());
setType(value);
}}
/>
</Label>
<PrivateKeyOrJson />
</Box>
</>
);
}

View File

@ -0,0 +1,11 @@
import React from 'react';
import NewAccountImportForm from '.';
export default {
title: 'Pages/CreateAccount/ImportAccount',
component: NewAccountImportForm,
};
export const DefaultStory = (args) => <NewAccountImportForm {...args} />;
DefaultStory.storyName = 'Default';

View File

@ -1,79 +1 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import Dropdown from '../../../components/ui/dropdown';
// Subviews
import ZENDESK_URLS from '../../../helpers/constants/zendesk-url';
import JsonImportView from './json';
import PrivateKeyImportView from './private-key';
export default class AccountImportSubview extends Component {
static contextTypes = {
t: PropTypes.func,
};
state = {};
getMenuItemTexts() {
return [this.context.t('privateKey'), this.context.t('jsonFile')];
}
renderImportView() {
const { type } = this.state;
const menuItems = this.getMenuItemTexts();
const current = type || menuItems[0];
switch (current) {
case this.context.t('privateKey'):
return <PrivateKeyImportView />;
case this.context.t('jsonFile'):
return <JsonImportView />;
default:
return <JsonImportView />;
}
}
render() {
const menuItems = this.getMenuItemTexts();
const { type } = this.state;
const { t } = this.context;
return (
<>
<div className="page-container__header">
<div className="page-container__title">{t('importAccount')}</div>
<div className="page-container__subtitle">
{t('importAccountMsg')}
<span
className="new-account-info-link"
onClick={() => {
global.platform.openTab({
url: ZENDESK_URLS.IMPORTED_ACCOUNTS,
});
}}
>
{t('here')}
</span>
</div>
</div>
<div className="new-account-import-form">
<div className="new-account-import-form__select-section">
<div className="new-account-import-form__select-label">
{t('selectType')}
</div>
<Dropdown
className="new-account-import-form__select"
options={menuItems.map((text) => ({ value: text }))}
selectedOption={type || menuItems[0]}
onChange={(value) => {
this.setState({ type: value });
}}
/>
</div>
{this.renderImportView()}
</div>
</>
);
}
}
export { default } from './import-account';

View File

@ -1,90 +0,0 @@
.new-account-info-link {
cursor: pointer;
text-decoration: underline;
color: var(--color-primary-default);
margin-inline-start: 4px;
}
.new-account-import-form {
display: flex;
flex-flow: column;
align-items: center;
padding: 0 30px 30px;
@include screen-sm-max {
padding: 0 22px 22px;
}
&__select-section {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 29px;
width: 100%;
}
&__select-label {
@include Paragraph;
color: var(--color-text-muted);
}
&__select {
width: 210px;
display: flex;
align-items: center;
}
&__private-key-password-container {
display: flex;
flex-flow: column;
align-items: center;
width: 100%;
}
&__instruction {
@include Paragraph;
color: var(--color-text-muted);
align-self: flex-start;
}
&__private-key {
display: flex;
flex-flow: column;
align-items: flex-start;
margin-top: 34px;
}
&__help-link {
color: var(--color-primary-default);
}
&__input-password {
@include Paragraph;
height: 54px;
width: 315px;
border: 1px solid var(--color-border-default);
border-radius: 4px;
background-color: var(--color-background-default);
color: var(--color-text-default);
margin-top: 16px;
padding: 0 20px;
}
&__json {
display: flex;
flex-flow: column;
align-items: center;
margin-top: 29px;
width: 100%;
}
&__buttons {
margin-top: 39px;
display: flex;
width: 100%;
justify-content: space-between;
}
}

View File

@ -1,189 +1,97 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { withRouter } from 'react-router-dom';
import { compose } from 'redux';
import { connect } from 'react-redux';
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import FileInput from 'react-simple-file-input';
import * as actions from '../../../store/actions';
import { getMetaMaskAccounts } from '../../../selectors';
import Button from '../../../components/ui/button';
import { EVENT, EVENT_NAMES } from '../../../../shared/constants/metametrics';
import { getMostRecentOverviewPage } from '../../../ducks/history/history';
import {
ButtonLink,
FormTextField,
Text,
TEXT_FIELD_SIZES,
TEXT_FIELD_TYPES,
} from '../../../components/component-library';
import {
Size,
TextVariant,
TEXT_ALIGN,
} from '../../../helpers/constants/design-system';
import ZENDESK_URLS from '../../../helpers/constants/zendesk-url';
import { useI18nContext } from '../../../hooks/useI18nContext';
import { displayWarning } from '../../../store/actions';
import BottomButtons from './bottom-buttons';
class JsonImportSubview extends Component {
state = {
fileContents: '',
isEmpty: true,
};
JsonImportSubview.propTypes = {
importAccountFunc: PropTypes.func.isRequired,
};
inputRef = React.createRef();
export default function JsonImportSubview({ importAccountFunc }) {
const t = useI18nContext();
const warning = useSelector((state) => state.appState.warning);
const [password, setPassword] = useState('');
const [fileContents, setFileContents] = useState('');
render() {
const { error, history, mostRecentOverviewPage } = this.props;
const enabled = !this.state.isEmpty && this.state.fileContents !== '';
const isPrimaryDisabled = password === '' || fileContents === '';
return (
<div className="new-account-import-form__json">
<p>{this.context.t('usedByClients')}</p>
<a
className="new-account-import-form__help-link"
function handleKeyPress(event) {
if (!isPrimaryDisabled && event.key === 'Enter') {
event.preventDefault();
_importAccountFunc();
}
}
function _importAccountFunc() {
if (isPrimaryDisabled) {
displayWarning(t('needImportFile'));
} else {
importAccountFunc('JSON File', [fileContents, password]);
}
}
return (
<>
<Text variant={TextVariant.bodyMd} textAlign={TEXT_ALIGN.CENTER}>
{t('usedByClients')}
<ButtonLink
size={Size.inherit}
href={ZENDESK_URLS.IMPORTED_ACCOUNTS}
target="_blank"
rel="noopener noreferrer"
>
{this.context.t('fileImportFail')}
</a>
<FileInput
readAs="text"
onLoad={this.onLoad.bind(this)}
style={{
padding: '20px 0px 12px 15%',
fontSize: '15px',
display: 'flex',
justifyContent: 'center',
width: '100%',
}}
/>
<input
className="new-account-import-form__input-password"
type="password"
placeholder={this.context.t('enterPassword')}
id="json-password-box"
onKeyPress={this.createKeyringOnEnter.bind(this)}
onChange={() => this.checkInputEmpty()}
ref={this.inputRef}
/>
<div className="new-account-create-form__buttons">
<Button
type="secondary"
large
className="new-account-create-form__button"
onClick={() => history.push(mostRecentOverviewPage)}
>
{this.context.t('cancel')}
</Button>
<Button
type="primary"
large
className="new-account-create-form__button"
onClick={() => this.createNewKeychain()}
disabled={!enabled}
>
{this.context.t('import')}
</Button>
</div>
{error ? <span className="error">{error}</span> : null}
</div>
);
}
{t('fileImportFail')}
</ButtonLink>
</Text>
onLoad(event) {
this.setState({
fileContents: event.target.result,
});
}
<FileInput
readAs="text"
onLoad={(event) => setFileContents(event.target.result)}
style={{
padding: '20px 0px 12px 15%',
fontSize: '16px',
display: 'flex',
justifyContent: 'center',
width: '100%',
}}
/>
createKeyringOnEnter(event) {
if (event.key === 'Enter') {
event.preventDefault();
this.createNewKeychain();
}
}
<FormTextField
id="json-password-box"
size={TEXT_FIELD_SIZES.LARGE}
autoFocus
type={TEXT_FIELD_TYPES.PASSWORD}
helpText={warning}
error
placeholder={t('enterPassword')}
value={password}
onChange={(event) => setPassword(event.target.value)}
inputProps={{
onKeyPress: handleKeyPress,
}}
marginBottom={4}
/>
createNewKeychain() {
const {
firstAddress,
displayWarning,
history,
importNewJsonAccount,
mostRecentOverviewPage,
setSelectedAddress,
} = this.props;
const { fileContents } = this.state;
const { t } = this.context;
if (!fileContents) {
const message = t('needImportFile');
displayWarning(message);
return;
}
const password = this.inputRef.current.value;
importNewJsonAccount([fileContents, password])
.then(({ selectedAddress }) => {
if (selectedAddress) {
history.push(mostRecentOverviewPage);
this.context.trackEvent({
category: EVENT.CATEGORIES.ACCOUNTS,
event: EVENT_NAMES.ACCOUNT_ADDED,
properties: {
account_type: EVENT.ACCOUNT_TYPES.IMPORTED,
account_import_type: EVENT.ACCOUNT_IMPORT_TYPES.JSON,
},
});
displayWarning(null);
} else {
displayWarning(t('importAccountError'));
this.context.trackEvent({
category: EVENT.CATEGORIES.ACCOUNTS,
event: EVENT_NAMES.ACCOUNT_ADD_FAILED,
properties: {
account_type: EVENT.ACCOUNT_TYPES.IMPORTED,
account_import_type: EVENT.ACCOUNT_IMPORT_TYPES.JSON,
},
});
setSelectedAddress(firstAddress);
}
})
.catch((err) => err && displayWarning(err.message || err));
}
checkInputEmpty() {
const password = this.inputRef.current.value;
let isEmpty = true;
if (password !== '') {
isEmpty = false;
}
this.setState({ isEmpty });
}
<BottomButtons
importAccountFunc={_importAccountFunc}
isPrimaryDisabled={isPrimaryDisabled}
/>
</>
);
}
JsonImportSubview.propTypes = {
error: PropTypes.string,
displayWarning: PropTypes.func,
firstAddress: PropTypes.string,
importNewJsonAccount: PropTypes.func,
history: PropTypes.object,
setSelectedAddress: PropTypes.func,
mostRecentOverviewPage: PropTypes.string.isRequired,
};
const mapStateToProps = (state) => {
return {
error: state.appState.warning,
firstAddress: Object.keys(getMetaMaskAccounts(state))[0],
mostRecentOverviewPage: getMostRecentOverviewPage(state),
};
};
const mapDispatchToProps = (dispatch) => {
return {
displayWarning: (warning) => dispatch(actions.displayWarning(warning)),
importNewJsonAccount: (options) =>
dispatch(actions.importNewAccount('JSON File', options)),
setSelectedAddress: (address) =>
dispatch(actions.setSelectedAddress(address)),
};
};
JsonImportSubview.contextTypes = {
t: PropTypes.func,
trackEvent: PropTypes.func,
};
export default compose(
withRouter,
connect(mapStateToProps, mapDispatchToProps),
)(JsonImportSubview);

View File

@ -0,0 +1,11 @@
import React from 'react';
import JsonImportSubview from './json';
export default {
title: 'Pages/CreateAccount/ImportAccount/JsonImportSubview',
component: JsonImportSubview,
};
export const DefaultStory = () => <JsonImportSubview />;
DefaultStory.storyName = 'Default';

View File

@ -1,160 +1,57 @@
import React, { Component } from 'react';
import { withRouter } from 'react-router-dom';
import { compose } from 'redux';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import * as actions from '../../../store/actions';
import { getMetaMaskAccounts } from '../../../selectors';
import Button from '../../../components/ui/button';
import { getMostRecentOverviewPage } from '../../../ducks/history/history';
import { EVENT, EVENT_NAMES } from '../../../../shared/constants/metametrics';
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import {
FormTextField,
TEXT_FIELD_SIZES,
TEXT_FIELD_TYPES,
} from '../../../components/component-library';
import { useI18nContext } from '../../../hooks/useI18nContext';
import BottomButtons from './bottom-buttons';
class PrivateKeyImportView extends Component {
static contextTypes = {
t: PropTypes.func,
trackEvent: PropTypes.func,
};
PrivateKeyImportView.propTypes = {
importAccountFunc: PropTypes.func.isRequired,
};
static propTypes = {
importNewAccount: PropTypes.func.isRequired,
history: PropTypes.object.isRequired,
displayWarning: PropTypes.func.isRequired,
setSelectedAddress: PropTypes.func.isRequired,
firstAddress: PropTypes.string.isRequired,
error: PropTypes.node,
mostRecentOverviewPage: PropTypes.string.isRequired,
};
export default function PrivateKeyImportView({ importAccountFunc }) {
const t = useI18nContext();
const [privateKey, setPrivateKey] = useState('');
inputRef = React.createRef();
const warning = useSelector((state) => state.appState.warning);
state = { isEmpty: true };
createNewKeychain() {
const privateKey = this.inputRef.current.value;
const {
importNewAccount,
history,
displayWarning,
mostRecentOverviewPage,
setSelectedAddress,
firstAddress,
} = this.props;
const { t } = this.context;
importNewAccount('Private Key', [privateKey])
.then(({ selectedAddress }) => {
if (selectedAddress) {
this.context.trackEvent({
category: EVENT.CATEGORIES.ACCOUNTS,
event: EVENT_NAMES.ACCOUNT_ADDED,
properties: {
account_type: EVENT.ACCOUNT_TYPES.IMPORTED,
account_import_type: EVENT.ACCOUNT_IMPORT_TYPES.PRIVATE_KEY,
},
});
history.push(mostRecentOverviewPage);
displayWarning(null);
} else {
displayWarning(t('importAccountError'));
this.context.trackEvent({
category: EVENT.CATEGORIES.ACCOUNTS,
event: EVENT_NAMES.ACCOUNT_ADD_FAILED,
properties: {
account_type: EVENT.ACCOUNT_TYPES.IMPORTED,
account_import_type: EVENT.ACCOUNT_IMPORT_TYPES.PRIVATE_KEY,
},
});
setSelectedAddress(firstAddress);
}
})
.catch((err) => err && displayWarning(err.message || err));
}
createKeyringOnEnter = (event) => {
if (event.key === 'Enter') {
function handleKeyPress(event) {
if (privateKey !== '' && event.key === 'Enter') {
event.preventDefault();
this.createNewKeychain();
_importAccountFunc();
}
};
checkInputEmpty() {
const privateKey = this.inputRef.current.value;
let isEmpty = true;
if (privateKey !== '') {
isEmpty = false;
}
this.setState({ isEmpty });
}
render() {
const { error, displayWarning } = this.props;
return (
<div className="new-account-import-form__private-key">
<span className="new-account-import-form__instruction">
{this.context.t('pastePrivateKey')}
</span>
<div className="new-account-import-form__private-key-password-container">
<input
className="new-account-import-form__input-password"
type="password"
id="private-key-box"
onKeyPress={(e) => this.createKeyringOnEnter(e)}
onChange={() => this.checkInputEmpty()}
ref={this.inputRef}
autoFocus
/>
</div>
<div className="new-account-import-form__buttons">
<Button
type="secondary"
large
className="new-account-create-form__button"
onClick={() => {
const { history, mostRecentOverviewPage } = this.props;
displayWarning(null);
history.push(mostRecentOverviewPage);
}}
>
{this.context.t('cancel')}
</Button>
<Button
type="primary"
large
className="new-account-create-form__button"
onClick={() => this.createNewKeychain()}
disabled={this.state.isEmpty}
>
{this.context.t('import')}
</Button>
</div>
{error ? <span className="error">{error}</span> : null}
</div>
);
function _importAccountFunc() {
importAccountFunc('Private Key', [privateKey]);
}
}
export default compose(
withRouter,
connect(mapStateToProps, mapDispatchToProps),
)(PrivateKeyImportView);
return (
<>
<FormTextField
id="private-key-box"
size={TEXT_FIELD_SIZES.LARGE}
autoFocus
type={TEXT_FIELD_TYPES.PASSWORD}
helpText={warning}
error
label={t('pastePrivateKey')}
value={privateKey}
onChange={(event) => setPrivateKey(event.target.value)}
inputProps={{
onKeyPress: handleKeyPress,
}}
marginBottom={4}
/>
function mapStateToProps(state) {
return {
error: state.appState.warning,
firstAddress: Object.keys(getMetaMaskAccounts(state))[0],
mostRecentOverviewPage: getMostRecentOverviewPage(state),
};
}
function mapDispatchToProps(dispatch) {
return {
importNewAccount: (strategy, [privateKey]) => {
return dispatch(actions.importNewAccount(strategy, [privateKey]));
},
displayWarning: (message) =>
dispatch(actions.displayWarning(message || null)),
setSelectedAddress: (address) =>
dispatch(actions.setSelectedAddress(address)),
};
<BottomButtons
importAccountFunc={_importAccountFunc}
isPrimaryDisabled={privateKey === ''}
/>
</>
);
}

View File

@ -0,0 +1,18 @@
import React from 'react';
import PrivateKeyImportView from './private-key';
export default {
title: 'Pages/CreateAccount/ImportAccount/PrivateKeyImportView',
component: PrivateKeyImportView,
argTypes: {
importAccountFunc: {
control: {
type: 'function',
},
},
},
};
export const DefaultStory = (args) => <PrivateKeyImportView {...args} />;
DefaultStory.storyName = 'Default';

View File

@ -1,5 +1,4 @@
@import 'connect-hardware/index';
@import 'import-account/index';
.new-account {
width: 375px;
@ -18,47 +17,8 @@
position: absolute;
}
&__header {
display: flex;
flex-flow: column;
border-bottom: 1px solid var(--color-border-muted);
}
&__title {
@include H2;
color: var(--color-text-alternative);
font-weight: 500;
margin-top: 22px;
margin-left: 29px;
}
&__tabs {
margin-left: 22px;
display: flex;
margin-top: 10px;
&__tab {
@include H4;
height: 54px;
padding: 15px 10px;
color: var(--color-text-muted);
text-align: center;
cursor: pointer;
}
&__tab:hover {
color: var(--color-text-default);
border-bottom: none;
}
&__selected {
color: var(--color-primary-default);
border-bottom: 3px solid var(--color-primary-default);
cursor: initial;
pointer-events: none;
}
.bottom-border-1px {
border-width: 0 0 1px 0;
}
}

View File

@ -1,9 +1,10 @@
import React from 'react';
import { action } from '@storybook/addon-actions';
import React from 'react';
import NewAccountCreateForm from './new-account.component';
export default {
title: 'Pages/CreateAccount/NewAccount',
component: NewAccountCreateForm,
argTypes: {
accounts: {
control: 'array',
@ -13,10 +14,9 @@ export default {
accounts: [],
},
};
export const DefaultStory = (args) => {
return (
<NewAccountCreateForm {...args} createAccount={action('Account Created')} />
);
};
export const DefaultStory = (args) => (
<NewAccountCreateForm {...args} createAccount={action('Account Created')} />
);
DefaultStory.storyName = 'Default';

View File

@ -350,9 +350,11 @@ describe('Actions', () => {
_setBackgroundConnection(background);
await store.dispatch(
actions.importNewAccount('Private Key', [
'c87509a1c067bbde78beb793e6fa76530b6382a4c0241e5e4a9ec0a0f44dc0d3',
]),
actions.importNewAccount(
'Private Key',
['c87509a1c067bbde78beb793e6fa76530b6382a4c0241e5e4a9ec0a0f44dc0d3'],
'',
),
);
expect(importAccountWithStrategy.callCount).toStrictEqual(1);
});
@ -369,9 +371,8 @@ describe('Actions', () => {
const expectedActions = [
{
type: 'SHOW_LOADING_INDICATION',
payload: 'This may take a while, please be patient.',
payload: undefined,
},
{ type: 'DISPLAY_WARNING', payload: 'error' },
{ type: 'HIDE_LOADING_INDICATION' },
];

File diff suppressed because it is too large Load Diff