Skip to content

Commit

Permalink
Adding periodic reminder modal for backing up recovery phrase (#11021)
Browse files Browse the repository at this point in the history
* Adding recurring recovery phrase reminder modal

* Refactoring per PR feedback
  • Loading branch information
ryanml committed Jun 22, 2021
1 parent 670aa1c commit 6a23866
Show file tree
Hide file tree
Showing 17 changed files with 350 additions and 10 deletions.
24 changes: 24 additions & 0 deletions app/_locales/en/messages.json
Expand Up @@ -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"
},
Expand Down
23 changes: 23 additions & 0 deletions app/scripts/controllers/app-state.js
Expand Up @@ -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;
Expand Down Expand Up @@ -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}
Expand Down
8 changes: 8 additions & 0 deletions app/scripts/metamask-controller.js
Expand Up @@ -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(
Expand Down
32 changes: 32 additions & 0 deletions app/scripts/migrations/061.js
@@ -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;
}
67 changes: 67 additions & 0 deletions app/scripts/migrations/061.test.js
@@ -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,
},
});
});
});
1 change: 1 addition & 0 deletions app/scripts/migrations/index.js
Expand Up @@ -65,6 +65,7 @@ const migrations = [
require('./058').default,
require('./059').default,
require('./060').default,
require('./061').default,
];

export default migrations;
5 changes: 5 additions & 0 deletions shared/constants/time.js
@@ -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;
1 change: 1 addition & 0 deletions ui/components/app/app-components.scss
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions ui/components/app/recovery-phrase-reminder/index.js
@@ -0,0 +1 @@
export { default } from './recovery-phrase-reminder';
10 changes: 10 additions & 0 deletions ui/components/app/recovery-phrase-reminder/index.scss
@@ -0,0 +1,10 @@
.recovery-phrase-reminder {
&__list {
list-style: disc;
padding-left: 20px;

li {
margin-bottom: 5px;
}
}
}
@@ -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,
};
4 changes: 4 additions & 0 deletions ui/components/ui/popover/index.scss
Expand Up @@ -47,6 +47,10 @@
margin-right: 24px;
}
}

&.center {
justify-content: center;
}
}

&__subtitle {
Expand Down
25 changes: 17 additions & 8 deletions ui/components/ui/popover/popover.component.js
Expand Up @@ -17,6 +17,7 @@ const Popover = ({
showArrow,
CustomBackground,
popoverRef,
centerTitle,
}) => {
const t = useI18nContext();
return (
Expand All @@ -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
Expand All @@ -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>
Expand Down Expand Up @@ -76,14 +84,15 @@ 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,
showArrow: PropTypes.bool,
popoverRef: PropTypes.shape({
current: PropTypes.instanceOf(window.Element),
}),
centerTitle: PropTypes.bool,
};

export default class PopoverPortal extends PureComponent {
Expand Down

0 comments on commit 6a23866

Please sign in to comment.