1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-12-23 01:39:44 +01:00

Prevent account name collisions (#16752)

* dealt with most of the problems in the Create Account dialog

* Fixed "newAccountNumberName" localizations

* In another language, don't allow accounts named, for instance, Cuenta 3

* Editing an account name later now follows the same rules

* Fixing lint errors

* Responding to the review by @adonesky1

* Worked with @montelaidev to alter the RegExp, in order to catch spaces before and after the account name

* Fixed line breaks for eslint
This commit is contained in:
HowardBraham 2022-12-22 09:27:31 -08:00 committed by GitHub
parent 3cf5ef642f
commit b9d9112b97
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 143 additions and 121 deletions

View File

@ -544,7 +544,7 @@
"message": "አዲስ መለያ" "message": "አዲስ መለያ"
}, },
"newAccountNumberName": { "newAccountNumberName": {
"message": "መለያ$1", "message": "መለያ $1",
"description": "Default name of next account to be created on create account screen" "description": "Default name of next account to be created on create account screen"
}, },
"newContact": { "newContact": {

View File

@ -144,6 +144,10 @@
"message": "This account name already exists", "message": "This account name already exists",
"description": "This is an error message shown when the user enters a new account name that matches an existing account name" "description": "This is an error message shown when the user enters a new account name that matches an existing account name"
}, },
"accountNameReserved": {
"message": "This account name is reserved",
"description": "This is an error message shown when the user enters a new account name that is reserved for future use"
},
"accountOptions": { "accountOptions": {
"message": "Account options" "message": "Account options"
}, },

View File

@ -559,7 +559,7 @@
"message": "حساب جدید" "message": "حساب جدید"
}, },
"newAccountNumberName": { "newAccountNumberName": {
"message": "حساب 1$1", "message": "حساب $1",
"description": "Default name of next account to be created on create account screen" "description": "Default name of next account to be created on create account screen"
}, },
"newContact": { "newContact": {

View File

@ -546,7 +546,7 @@
"message": "Ny konto " "message": "Ny konto "
}, },
"newAccountNumberName": { "newAccountNumberName": {
"message": "Konto $1 ", "message": "Konto $1",
"description": "Default name of next account to be created on create account screen" "description": "Default name of next account to be created on create account screen"
}, },
"newContact": { "newContact": {

View File

@ -282,7 +282,7 @@
"message": "புதிய கணக்கு" "message": "புதிய கணக்கு"
}, },
"newAccountNumberName": { "newAccountNumberName": {
"message": "கணக்கு $ 1", "message": "கணக்கு $1",
"description": "Default name of next account to be created on create account screen" "description": "Default name of next account to be created on create account screen"
}, },
"newContract": { "newContract": {

View File

@ -41,8 +41,8 @@
"sentry:publish": "node ./development/sentry-publish.js", "sentry:publish": "node ./development/sentry-publish.js",
"lint": "yarn lint:prettier && yarn lint:eslint && yarn lint:tsc && yarn lint:styles", "lint": "yarn lint:prettier && yarn lint:eslint && yarn lint:tsc && yarn lint:styles",
"lint:fix": "yarn lint:prettier:fix && yarn lint:eslint:fix && yarn lint:styles:fix", "lint:fix": "yarn lint:prettier:fix && yarn lint:eslint:fix && yarn lint:styles:fix",
"lint:prettier": "prettier '**/*.json' --check", "lint:prettier": "prettier --check -- **/*.json",
"lint:prettier:fix": "prettier '**/*.json' --write", "lint:prettier:fix": "prettier --write -- **/*.json",
"lint:changed": "{ git ls-files --others --exclude-standard ; git diff-index --name-only --diff-filter=d HEAD ; } | grep --regexp='[.]js$' | tr '\\n' '\\0' | xargs -0 eslint", "lint:changed": "{ git ls-files --others --exclude-standard ; git diff-index --name-only --diff-filter=d HEAD ; } | grep --regexp='[.]js$' | tr '\\n' '\\0' | xargs -0 eslint",
"lint:changed:fix": "{ git ls-files --others --exclude-standard ; git diff-index --name-only --diff-filter=d HEAD ; } | grep --regexp='[.]js$' | tr '\\n' '\\0' | xargs -0 eslint --fix", "lint:changed:fix": "{ git ls-files --others --exclude-standard ; git diff-index --name-only --diff-filter=d HEAD ; } | grep --regexp='[.]js$' | tr '\\n' '\\0' | xargs -0 eslint --fix",
"lint:changelog": "auto-changelog validate", "lint:changelog": "auto-changelog validate",
@ -53,7 +53,7 @@
"lint:lockfile:dedupe:fix": "yarn dedupe", "lint:lockfile:dedupe:fix": "yarn dedupe",
"lint:lockfile": "lockfile-lint --path yarn.lock --allowed-hosts npm yarn github.com codeload.github.com --empty-hostname true --allowed-schemes \"https:\" \"git+https:\" \"npm:\" \"patch:\" \"workspace:\"", "lint:lockfile": "lockfile-lint --path yarn.lock --allowed-hosts npm yarn github.com codeload.github.com --empty-hostname true --allowed-schemes \"https:\" \"git+https:\" \"npm:\" \"patch:\" \"workspace:\"",
"lint:shellcheck": "./development/shellcheck.sh", "lint:shellcheck": "./development/shellcheck.sh",
"lint:styles": "stylelint '*/**/*.scss'", "lint:styles": "stylelint -- */**/*.scss",
"lint:styles:fix": "yarn lint:styles --fix", "lint:styles:fix": "yarn lint:styles --fix",
"lint:tsc": "tsc --project tsconfig.json --noEmit", "lint:tsc": "tsc --project tsconfig.json --noEmit",
"validate-source-maps": "node ./development/sourcemap-validator.js", "validate-source-maps": "node ./development/sourcemap-validator.js",

View File

@ -41,7 +41,6 @@ export default class AccountDetailsModal extends Component {
setAccountLabel, setAccountLabel,
keyrings, keyrings,
rpcPrefs, rpcPrefs,
accounts,
history, history,
hideModal, hideModal,
blockExplorerLinkText, blockExplorerLinkText,
@ -52,12 +51,6 @@ export default class AccountDetailsModal extends Component {
return kr.accounts.includes(address); return kr.accounts.includes(address);
}); });
const getAccountsNames = (allAccounts, currentName) => {
return Object.values(allAccounts)
.map((item) => item.name)
.filter((itemName) => itemName !== currentName);
};
let exportPrivateKeyFeatureEnabled = true; let exportPrivateKeyFeatureEnabled = true;
// This feature is disabled for hardware wallets // This feature is disabled for hardware wallets
if (isHardwareKeyring(keyring?.type)) { if (isHardwareKeyring(keyring?.type)) {
@ -91,7 +84,7 @@ export default class AccountDetailsModal extends Component {
className="account-details-modal__name" className="account-details-modal__name"
defaultValue={name} defaultValue={name}
onSubmit={(label) => setAccountLabel(address, label)} onSubmit={(label) => setAccountLabel(address, label)}
accountsNames={getAccountsNames(accounts, name)} accounts={this.props.accounts}
/> />
<QrView <QrView
@ -111,15 +104,12 @@ export default class AccountDetailsModal extends Component {
: openBlockExplorer : openBlockExplorer
} }
> >
{this.context.t( {this.context.t(blockExplorerLinkText.firstPart, [
blockExplorerLinkText.firstPart, blockExplorerLinkText.secondPart,
blockExplorerLinkText.secondPart === '' ])}
? null
: [blockExplorerLinkText.secondPart],
)}
</Button> </Button>
{exportPrivateKeyFeatureEnabled ? ( {exportPrivateKeyFeatureEnabled && (
<Button <Button
type="secondary" type="secondary"
className="account-details-modal__button" className="account-details-modal__button"
@ -137,7 +127,7 @@ export default class AccountDetailsModal extends Component {
> >
{this.context.t('exportPrivateKey')} {this.context.t('exportPrivateKey')}
</Button> </Button>
) : null} )}
</AccountModalContainer> </AccountModalContainer>
); );
} }

View File

@ -1,13 +1,14 @@
import classnames from 'classnames'; import classnames from 'classnames';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { getAccountNameErrorMessage } from '../../../helpers/utils/accounts';
class EditableLabel extends Component { export default class EditableLabel extends Component {
static propTypes = { static propTypes = {
onSubmit: PropTypes.func.isRequired, onSubmit: PropTypes.func.isRequired,
defaultValue: PropTypes.string, defaultValue: PropTypes.string,
className: PropTypes.string, className: PropTypes.string,
accountsNames: PropTypes.array, accounts: PropTypes.array,
}; };
static contextTypes = { static contextTypes = {
@ -19,91 +20,71 @@ class EditableLabel extends Component {
value: this.props.defaultValue || '', value: this.props.defaultValue || '',
}; };
handleSubmit() { async handleSubmit(isValidAccountName) {
const { value } = this.state; if (!isValidAccountName) {
const { accountsNames } = this.props;
if (value === '' || accountsNames.includes(value)) {
return; return;
} }
Promise.resolve(this.props.onSubmit(value)).then(() => await this.props.onSubmit(this.state.value);
this.setState({ isEditing: false }), this.setState({ isEditing: false });
);
} }
renderEditing() { renderEditing() {
const { value } = this.state; const { isValidAccountName, errorMessage } = getAccountNameErrorMessage(
const { accountsNames } = this.props; this.props.accounts,
this.context,
this.state.value,
this.props.defaultValue,
);
return [ return (
<input <div className={classnames('editable-label', this.props.className)}>
key={1} <input
type="text" type="text"
required required
dir="auto" dir="auto"
value={this.state.value} value={this.state.value}
onKeyPress={(event) => { onKeyPress={(event) => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
this.handleSubmit(); this.handleSubmit(isValidAccountName);
} }
}} }}
onChange={(event) => this.setState({ value: event.target.value })} onChange={(event) => this.setState({ value: event.target.value })}
data-testid="editable-input" data-testid="editable-input"
className={classnames('large-input', 'editable-label__input', { className={classnames('large-input', 'editable-label__input', {
'editable-label__input--error': 'editable-label__input--error': !isValidAccountName,
value === '' || accountsNames.includes(value), })}
})} autoFocus
autoFocus />
/>, <button
<button className="editable-label__icon-button"
className="editable-label__icon-button" onClick={() => this.handleSubmit(isValidAccountName)}
key={2} >
onClick={() => this.handleSubmit()} <i className="fa fa-check editable-label__icon" />
> </button>
<i className="fa fa-check editable-label__icon" /> <div className="editable-label__error editable-label__error-amount">
</button>, {errorMessage}
]; </div>
</div>
);
} }
renderReadonly() { renderReadonly() {
return [ return (
<div key={1} className="editable-label__value"> <div className={classnames('editable-label', this.props.className)}>
{this.state.value} <div className="editable-label__value">{this.state.value}</div>
</div>, <button
<button className="editable-label__icon-button"
key={2} data-testid="editable-label-button"
className="editable-label__icon-button" onClick={() => this.setState({ isEditing: true })}
data-testid="editable-label-button" >
onClick={() => this.setState({ isEditing: true })} <i className="fas fa-pencil-alt editable-label__icon" />
> </button>
<i className="fas fa-pencil-alt editable-label__icon" /> </div>
</button>, );
];
} }
render() { render() {
const { isEditing, value } = this.state; return this.state.isEditing ? this.renderEditing() : this.renderReadonly();
const { className, accountsNames } = this.props;
return (
<>
<div className={classnames('editable-label', className)}>
{isEditing ? this.renderEditing() : this.renderReadonly()}
</div>
{accountsNames.includes(value) ? (
<div
className={classnames(
'editable-label__error',
'editable-label__error-amount',
)}
>
{this.context.t('accountNameDuplicate')}
</div>
) : null}
</>
);
} }
} }
export default EditableLabel;

View File

@ -14,13 +14,20 @@ export default {
className: { className: {
control: 'text', control: 'text',
}, },
accountsNames: { accounts: {
control: 'array', control: 'array',
}, },
}, },
args: { args: {
defaultValue: 'Account 3', defaultValue: 'Account 3',
accountsNames: ['Account 1', 'Account 2'], accounts: [
{
name: 'Account 1',
},
{
name: 'Account 2',
},
],
}, },
}; };

View File

@ -3,6 +3,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
position: relative; position: relative;
flex-flow: wrap;
&__value { &__value {
max-width: 250px; max-width: 250px;
@ -26,7 +27,6 @@
} }
&__icon-button { &__icon-button {
position: absolute;
margin-left: 10px; margin-left: 10px;
left: 100%; left: 100%;
background: unset; background: unset;
@ -42,6 +42,8 @@
left: 8px; left: 8px;
color: var(--color-error-default); color: var(--color-error-default);
width: 100%;
text-align: center;
} }
&__error-amount { &__error-amount {

View File

@ -0,0 +1,37 @@
export function getAccountNameErrorMessage(
accounts,
context,
newAccountName,
defaultAccountName,
) {
const isDuplicateAccountName = accounts.some(
(item) => item.name === newAccountName,
);
const localizedWordForAccount = context
.t('newAccountNumberName')
.replace(' $1', '');
// Match strings starting with ${localizedWordForAccount} and then any numeral, case insensitive
// Trim spaces before and after
const reservedRegEx = new RegExp(
`^\\s*${localizedWordForAccount} \\d+\\s*$`,
'iu',
);
const isReservedAccountName = reservedRegEx.test(newAccountName);
const isValidAccountName =
newAccountName === defaultAccountName || // What is written in the text field is the same as the placeholder
(!isDuplicateAccountName && !isReservedAccountName);
let errorMessage;
if (isValidAccountName) {
errorMessage = '\u200d'; // This is Unicode for an invisible character, so the spacing stays constant
} else if (isDuplicateAccountName) {
errorMessage = context.t('accountNameDuplicate');
} else if (isReservedAccountName) {
errorMessage = context.t('accountNameReserved');
}
return { isValidAccountName, errorMessage };
}

View File

@ -84,13 +84,18 @@
border: 1px solid var(--color-border-muted); border: 1px solid var(--color-border-muted);
border-radius: 4px; border-radius: 4px;
background-color: var(--color-background-default); background-color: var(--color-background-default);
color: var(--color-text-muted); color: var(--color-text-default);
margin-top: 15px; margin-top: 15px;
padding: 0 20px; padding: 0 20px;
&__error { &__error {
border: 1px solid var(--color-error-alternative); border: 1px solid var(--color-error-alternative);
} }
&::placeholder {
color: var(--color-text-muted);
opacity: 1;
}
} }
&__error { &__error {
@ -105,7 +110,7 @@
} }
&__buttons { &__buttons {
margin-top: 39px; margin-top: 22px;
display: flex; display: flex;
width: 100%; width: 100%;
justify-content: space-between; justify-content: space-between;

View File

@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import classnames from 'classnames'; import classnames from 'classnames';
import Button from '../../components/ui/button'; import Button from '../../components/ui/button';
import { EVENT, EVENT_NAMES } from '../../../shared/constants/metametrics'; import { EVENT, EVENT_NAMES } from '../../../shared/constants/metametrics';
import { getAccountNameErrorMessage } from '../../helpers/utils/accounts';
export default class NewAccountCreateForm extends Component { export default class NewAccountCreateForm extends Component {
static defaultProps = { static defaultProps = {
@ -46,11 +47,12 @@ export default class NewAccountCreateForm extends Component {
}); });
}; };
const accountNameExists = (allAccounts, accountName) => { const { isValidAccountName, errorMessage } = getAccountNameErrorMessage(
return Boolean(allAccounts.find((item) => item.name === accountName)); accounts,
}; this.context,
newAccountName,
const existingAccountName = accountNameExists(accounts, newAccountName); defaultAccountName,
);
return ( return (
<div className="new-account-create-form"> <div className="new-account-create-form">
@ -59,8 +61,9 @@ export default class NewAccountCreateForm extends Component {
</div> </div>
<div> <div>
<input <input
className={classnames('new-account-create-form__input', { className={classnames({
'new-account-create-form__input__error': existingAccountName, 'new-account-create-form__input': true,
'new-account-create-form__input__error': !isValidAccountName,
})} })}
value={newAccountName} value={newAccountName}
placeholder={defaultAccountName} placeholder={defaultAccountName}
@ -69,16 +72,9 @@ export default class NewAccountCreateForm extends Component {
} }
autoFocus autoFocus
/> />
{existingAccountName ? ( <div className="new-account-create-form__error new-account-create-form__error-amount">
<div {errorMessage}
className={classnames( </div>
' new-account-create-form__error',
' new-account-create-form__error-amount',
)}
>
{this.context.t('accountNameDuplicate')}
</div>
) : null}
<div className="new-account-create-form__buttons"> <div className="new-account-create-form__buttons">
<Button <Button
type="secondary" type="secondary"
@ -93,7 +89,7 @@ export default class NewAccountCreateForm extends Component {
large large
className="new-account-create-form__button" className="new-account-create-form__button"
onClick={createClick} onClick={createClick}
disabled={existingAccountName} disabled={!isValidAccountName}
> >
{this.context.t('create')} {this.context.t('create')}
</Button> </Button>