1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-11-22 01:47:00 +01:00

Adding periodic reminder modal for backing up recovery phrase (#11021)

* Adding recurring recovery phrase reminder modal

* Refactoring per PR feedback
This commit is contained in:
ryanml 2021-06-04 23:33:58 -07:00 committed by GitHub
parent e76762548c
commit 9932c40651
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 350 additions and 10 deletions

View File

@ -1457,6 +1457,30 @@
"recipientAddressPlaceholder": {
"message": "Search, public address (0x), or ENS"
},
"recoveryPhraseReminderBackupStart": {
"message": "Start here"
},
"recoveryPhraseReminderConfirm": {
"message": "Got it"
},
"recoveryPhraseReminderHasBackedUp": {
"message": "Always keep your Secret Recovery Phrase in a secure and secret place"
},
"recoveryPhraseReminderHasNotBackedUp": {
"message": "Need to backup your Secret Recovery Phrase again?"
},
"recoveryPhraseReminderItemOne": {
"message": "Never share your Secret Recovery Phrase with anyone"
},
"recoveryPhraseReminderItemTwo": {
"message": "The MetaMask team will never ask for your Secret Recovery Phrase"
},
"recoveryPhraseReminderSubText": {
"message": "Your Secret Recovery Phrase controls all of your accounts."
},
"recoveryPhraseReminderTitle": {
"message": "Protect your funds"
},
"reject": {
"message": "Reject"
},

View File

@ -24,6 +24,8 @@ export default class AppStateController extends EventEmitter {
connectedStatusPopoverHasBeenShown: true,
defaultHomeActiveTabName: null,
browserEnvironment: {},
recoveryPhraseReminderHasBeenShown: false,
recoveryPhraseReminderLastShown: new Date().getTime(),
...initState,
});
this.timer = null;
@ -112,6 +114,27 @@ export default class AppStateController extends EventEmitter {
});
}
/**
* Record that the user has been shown the recovery phrase reminder
* @returns {void}
*/
setRecoveryPhraseReminderHasBeenShown() {
this.store.updateState({
recoveryPhraseReminderHasBeenShown: true,
});
}
/**
* Record the timestamp of the last time the user has seen the recovery phrase reminder
* @param {number} lastShown - timestamp when user was last shown the reminder
* @returns {void}
*/
setRecoveryPhraseReminderLastShown(lastShown) {
this.store.updateState({
recoveryPhraseReminderLastShown: lastShown,
});
}
/**
* Sets the last active time to the current time
* @returns {void}

View File

@ -780,6 +780,14 @@ export default class MetamaskController extends EventEmitter {
this.appStateController.setConnectedStatusPopoverHasBeenShown,
this.appStateController,
),
setRecoveryPhraseReminderHasBeenShown: nodeify(
this.appStateController.setRecoveryPhraseReminderHasBeenShown,
this.appStateController,
),
setRecoveryPhraseReminderLastShown: nodeify(
this.appStateController.setRecoveryPhraseReminderLastShown,
this.appStateController,
),
// EnsController
tryReverseResolveAddress: nodeify(

View File

@ -0,0 +1,32 @@
import { cloneDeep } from 'lodash';
const version = 61;
/**
* Initialize attributes related to recovery seed phrase reminder
*/
export default {
version,
async migrate(originalVersionedData) {
const versionedData = cloneDeep(originalVersionedData);
versionedData.meta.version = version;
const state = versionedData.data;
const newState = transformState(state);
versionedData.data = newState;
return versionedData;
},
};
function transformState(state) {
const currentTime = new Date().getTime();
if (state.AppStateController) {
state.AppStateController.recoveryPhraseReminderHasBeenShown = false;
state.AppStateController.recoveryPhraseReminderLastShown = currentTime;
} else {
state.AppStateController = {
recoveryPhraseReminderHasBeenShown: false,
recoveryPhraseReminderLastShown: currentTime,
};
}
return state;
}

View File

@ -0,0 +1,67 @@
import { strict as assert } from 'assert';
import sinon from 'sinon';
import migration61 from './061';
describe('migration #61', function () {
let dateStub;
beforeEach(function () {
dateStub = sinon.stub(Date.prototype, 'getTime').returns(1621580400000);
});
afterEach(function () {
dateStub.restore();
});
it('should update the version metadata', async function () {
const oldStorage = {
meta: {
version: 60,
},
data: {},
};
const newStorage = await migration61.migrate(oldStorage);
assert.deepEqual(newStorage.meta, {
version: 61,
});
});
it('should set recoveryPhraseReminderHasBeenShown to false and recoveryPhraseReminderLastShown to the current time', async function () {
const oldStorage = {
meta: {},
data: {
AppStateController: {
existingProperty: 'foo',
},
},
};
const newStorage = await migration61.migrate(oldStorage);
assert.deepEqual(newStorage.data, {
AppStateController: {
recoveryPhraseReminderHasBeenShown: false,
recoveryPhraseReminderLastShown: 1621580400000,
existingProperty: 'foo',
},
});
});
it('should initialize AppStateController if it does not exist', async function () {
const oldStorage = {
meta: {},
data: {
existingProperty: 'foo',
},
};
const newStorage = await migration61.migrate(oldStorage);
assert.deepEqual(newStorage.data, {
existingProperty: 'foo',
AppStateController: {
recoveryPhraseReminderHasBeenShown: false,
recoveryPhraseReminderLastShown: 1621580400000,
},
});
});
});

View File

@ -65,6 +65,7 @@ const migrations = [
require('./058').default,
require('./059').default,
require('./060').default,
require('./061').default,
];
export default migrations;

5
shared/constants/time.js Normal file
View File

@ -0,0 +1,5 @@
export const MILLISECOND = 1;
export const SECOND = MILLISECOND * 1000;
export const MINUTE = SECOND * 60;
export const HOUR = MINUTE * 60;
export const DAY = HOUR * 24;

View File

@ -23,6 +23,7 @@
@import 'permission-page-container/index';
@import 'permissions-connect-footer/index';
@import 'permissions-connect-header/index';
@import 'recovery-phrase-reminder/index';
@import 'selected-account/index';
@import 'sidebars/index';
@import 'signature-request/index';

View File

@ -0,0 +1 @@
export { default } from './recovery-phrase-reminder';

View File

@ -0,0 +1,10 @@
.recovery-phrase-reminder {
&__list {
list-style: disc;
padding-left: 20px;
li {
margin-bottom: 5px;
}
}
}

View File

@ -0,0 +1,91 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useHistory } from 'react-router-dom';
import { useI18nContext } from '../../../hooks/useI18nContext';
// Components
import Box from '../../ui/box';
import Button from '../../ui/button';
import Popover from '../../ui/popover';
import Typography from '../../ui/typography';
// Helpers
import {
COLORS,
DISPLAY,
TEXT_ALIGN,
TYPOGRAPHY,
BLOCK_SIZES,
FONT_WEIGHT,
JUSTIFY_CONTENT,
} from '../../../helpers/constants/design-system';
import { INITIALIZE_BACKUP_SEED_PHRASE_ROUTE } from '../../../helpers/constants/routes';
export default function RecoveryPhraseReminder({ onConfirm, hasBackedUp }) {
const t = useI18nContext();
const history = useHistory();
const handleBackUp = () => {
history.push(INITIALIZE_BACKUP_SEED_PHRASE_ROUTE);
};
return (
<Popover centerTitle title={t('recoveryPhraseReminderTitle')}>
<Box padding={[0, 4, 6, 4]} className="recovery-phrase-reminder">
<Typography
color={COLORS.BLACK}
align={TEXT_ALIGN.CENTER}
variant={TYPOGRAPHY.Paragraph}
boxProps={{ marginTop: 0, marginBottom: 4 }}
>
{t('recoveryPhraseReminderSubText')}
</Typography>
<Box margin={[4, 0, 8, 0]}>
<ul className="recovery-phrase-reminder__list">
<li>
<Typography
tag="span"
color={COLORS.BLACK}
fontWeight={FONT_WEIGHT.BOLD}
>
{t('recoveryPhraseReminderItemOne')}
</Typography>
</li>
<li>{t('recoveryPhraseReminderItemTwo')}</li>
<li>
{hasBackedUp ? (
t('recoveryPhraseReminderHasBackedUp')
) : (
<>
{t('recoveryPhraseReminderHasNotBackedUp')}
<Box display={DISPLAY.INLINE_BLOCK} marginLeft={1}>
<Button
type="link"
onClick={handleBackUp}
style={{
fontSize: 'inherit',
padding: 0,
}}
>
{t('recoveryPhraseReminderBackupStart')}
</Button>
</Box>
</>
)}
</li>
</ul>
</Box>
<Box justifyContent={JUSTIFY_CONTENT.CENTER}>
<Box width={BLOCK_SIZES.TWO_FIFTHS}>
<Button rounded type="primary" onClick={onConfirm}>
{t('recoveryPhraseReminderConfirm')}
</Button>
</Box>
</Box>
</Box>
</Popover>
);
}
RecoveryPhraseReminder.propTypes = {
hasBackedUp: PropTypes.bool.isRequired,
onConfirm: PropTypes.func.isRequired,
};

View File

@ -47,6 +47,10 @@
margin-right: 24px;
}
}
&.center {
justify-content: center;
}
}
&__subtitle {

View File

@ -17,6 +17,7 @@ const Popover = ({
showArrow,
CustomBackground,
popoverRef,
centerTitle,
}) => {
const t = useI18nContext();
return (
@ -32,7 +33,12 @@ const Popover = ({
>
{showArrow ? <div className="popover-arrow" /> : null}
<header className="popover-header">
<div className="popover-header__title">
<div
className={classnames(
'popover-header__title',
centerTitle ? 'center' : '',
)}
>
<h2 title={title}>
{onBack ? (
<button
@ -43,12 +49,14 @@ const Popover = ({
) : null}
{title}
</h2>
<button
className="fas fa-times popover-header__button"
title={t('close')}
data-testid="popover-close"
onClick={onClose}
/>
{onClose ? (
<button
className="fas fa-times popover-header__button"
title={t('close')}
data-testid="popover-close"
onClick={onClose}
/>
) : null}
</div>
{subtitle ? (
<p className="popover-header__subtitle">{subtitle}</p>
@ -76,7 +84,7 @@ Popover.propTypes = {
footer: PropTypes.node,
footerClassName: PropTypes.string,
onBack: PropTypes.func,
onClose: PropTypes.func.isRequired,
onClose: PropTypes.func,
CustomBackground: PropTypes.func,
contentClassName: PropTypes.string,
className: PropTypes.string,
@ -84,6 +92,7 @@ Popover.propTypes = {
popoverRef: PropTypes.shape({
current: PropTypes.instanceOf(window.Element),
}),
centerTitle: PropTypes.bool,
};
export default class PopoverPortal extends PureComponent {

View File

@ -14,6 +14,7 @@ import ConnectedAccounts from '../connected-accounts';
import { Tabs, Tab } from '../../components/ui/tabs';
import { EthOverview } from '../../components/app/wallet-overview';
import WhatsNewPopup from '../../components/app/whats-new-popup';
import RecoveryPhraseReminder from '../../components/app/recovery-phrase-reminder';
import {
ASSET_ROUTE,
@ -76,6 +77,10 @@ export default class Home extends PureComponent {
showWhatsNewPopup: PropTypes.bool.isRequired,
hideWhatsNewPopup: PropTypes.func.isRequired,
notificationsToShow: PropTypes.bool.isRequired,
showRecoveryPhraseReminder: PropTypes.bool.isRequired,
setRecoveryPhraseReminderHasBeenShown: PropTypes.func.isRequired,
setRecoveryPhraseReminderLastShown: PropTypes.func.isRequired,
seedPhraseBackedUp: PropTypes.bool.isRequired,
};
state = {
@ -163,6 +168,15 @@ export default class Home extends PureComponent {
}
}
onRecoveryPhraseReminderClose = () => {
const {
setRecoveryPhraseReminderHasBeenShown,
setRecoveryPhraseReminderLastShown,
} = this.props;
setRecoveryPhraseReminderHasBeenShown(true);
setRecoveryPhraseReminderLastShown(new Date().getTime());
};
renderNotifications() {
const { t } = this.context;
const {
@ -325,6 +339,8 @@ export default class Home extends PureComponent {
notificationsToShow,
showWhatsNewPopup,
hideWhatsNewPopup,
seedPhraseBackedUp,
showRecoveryPhraseReminder,
} = this.props;
if (forgottenPassword) {
@ -333,6 +349,8 @@ export default class Home extends PureComponent {
return null;
}
const showWhatsNew = notificationsToShow && showWhatsNewPopup;
return (
<div className="main-container">
<Route path={CONNECTED_ROUTE} component={ConnectedSites} exact />
@ -342,8 +360,12 @@ export default class Home extends PureComponent {
exact
/>
<div className="home__container">
{notificationsToShow && showWhatsNewPopup ? (
<WhatsNewPopup onClose={hideWhatsNewPopup} />
{showWhatsNew ? <WhatsNewPopup onClose={hideWhatsNewPopup} /> : null}
{!showWhatsNew && showRecoveryPhraseReminder ? (
<RecoveryPhraseReminder
hasBackedUp={seedPhraseBackedUp}
onConfirm={this.onRecoveryPhraseReminderClose}
/>
) : null}
{isPopup && !connectedStatusPopoverHasBeenShown
? this.renderPopover()

View File

@ -14,6 +14,7 @@ import {
getInfuraBlocked,
getShowWhatsNewPopup,
getSortedNotificationsToShow,
getShowRecoveryPhraseReminder,
} from '../../selectors';
import {
@ -25,6 +26,8 @@ import {
setDefaultHomeActiveTabName,
setWeb3ShimUsageAlertDismissed,
setAlertEnabledness,
setRecoveryPhraseReminderHasBeenShown,
setRecoveryPhraseReminderLastShown,
} from '../../store/actions';
import { setThreeBoxLastUpdated, hideWhatsNewPopup } from '../../ducks/app/app';
import { getWeb3ShimUsageAlertEnabledness } from '../../ducks/metamask/metamask';
@ -107,6 +110,8 @@ const mapStateToProps = (state) => {
infuraBlocked: getInfuraBlocked(state),
notificationsToShow: getSortedNotificationsToShow(state).length > 0,
showWhatsNewPopup: getShowWhatsNewPopup(state),
showRecoveryPhraseReminder: getShowRecoveryPhraseReminder(state),
seedPhraseBackedUp,
};
};
@ -132,6 +137,10 @@ const mapDispatchToProps = (dispatch) => ({
disableWeb3ShimUsageAlert: () =>
setAlertEnabledness(ALERT_TYPES.web3ShimUsage, false),
hideWhatsNewPopup: () => dispatch(hideWhatsNewPopup()),
setRecoveryPhraseReminderHasBeenShown: () =>
dispatch(setRecoveryPhraseReminderHasBeenShown()),
setRecoveryPhraseReminderLastShown: (lastShown) =>
dispatch(setRecoveryPhraseReminderLastShown(lastShown)),
});
export default compose(

View File

@ -23,6 +23,7 @@ import {
import { TEMPLATED_CONFIRMATION_MESSAGE_TYPES } from '../pages/confirmation/templates';
import { toChecksumHexAddress } from '../../shared/modules/hexstring-utils';
import { DAY } from '../../shared/constants/time';
import { getNativeCurrency } from './send';
/**
@ -563,3 +564,15 @@ export function getSortedNotificationsToShow(state) {
);
return notificationsSortedByDate;
}
export function getShowRecoveryPhraseReminder(state) {
const {
recoveryPhraseReminderLastShown,
recoveryPhraseReminderHasBeenShown,
} = state.metamask;
const currentTime = new Date().getTime();
const frequency = recoveryPhraseReminderHasBeenShown ? DAY * 90 : DAY * 2;
return currentTime - recoveryPhraseReminderLastShown >= frequency;
}

View File

@ -2570,6 +2570,26 @@ export function setConnectedStatusPopoverHasBeenShown() {
};
}
export function setRecoveryPhraseReminderHasBeenShown() {
return () => {
background.setRecoveryPhraseReminderHasBeenShown((err) => {
if (err) {
throw new Error(err.message);
}
});
};
}
export function setRecoveryPhraseReminderLastShown(lastShown) {
return () => {
background.setRecoveryPhraseReminderLastShown(lastShown, (err) => {
if (err) {
throw new Error(err.message);
}
});
};
}
export async function setAlertEnabledness(alertId, enabledness) {
await promisifiedBackground.setAlertEnabledness(alertId, enabledness);
}