From df52521c171bb6d159d863513253751e35586d63 Mon Sep 17 00:00:00 2001 From: William O'Beirne Date: Fri, 26 Jan 2018 14:53:51 -0500 Subject: [PATCH 01/72] MEW-01-009 & MEW-01-010: Electron security fixes (#910) * Handle opening of external links in electron. Minor refactor of window code. * Convert updates from in-app modal to electron dialogs. Remove in-app code and preload bridge. * Refine new window blocking. Re-enable tsconfig to look at electron-app. * Clean up shared * Whoops, wrong protocol format --- .../components/Header/components/Version.scss | 61 ------- .../components/Header/components/Version.tsx | 46 +----- common/components/UpdateModal.scss | 62 ------- common/components/UpdateModal.tsx | 142 ---------------- common/components/ui/NewTabLink.tsx | 9 +- common/utils/electron.ts | 25 --- electron-app/main/index.ts | 72 +-------- electron-app/main/updater.ts | 151 ++++++++++++------ electron-app/main/window.ts | 78 +++++++++ electron-app/preload.ts | 19 --- jest_config/jest.config.json | 3 +- jest_config/jest.int.config.json | 3 +- shared/electronBridge.ts | 9 -- shared/electronEvents.ts | 12 -- tsconfig.json | 2 +- webpack_config/webpack.electron-dev.js | 3 +- 16 files changed, 188 insertions(+), 509 deletions(-) delete mode 100644 common/components/Header/components/Version.scss delete mode 100644 common/components/UpdateModal.scss delete mode 100644 common/components/UpdateModal.tsx delete mode 100644 common/utils/electron.ts create mode 100644 electron-app/main/window.ts delete mode 100644 electron-app/preload.ts delete mode 100644 shared/electronBridge.ts delete mode 100644 shared/electronEvents.ts diff --git a/common/components/Header/components/Version.scss b/common/components/Header/components/Version.scss deleted file mode 100644 index 03a64a76..00000000 --- a/common/components/Header/components/Version.scss +++ /dev/null @@ -1,61 +0,0 @@ -@import 'common/sass/variables'; - -@keyframes new-update-popin { - 0% { - opacity: 0; - transform: scale(0.7); - } - 60% { - opacity: 1; - transform: scale(1.2); - } - 100% { - opacity: 1; - transform: scale(1); - } -} - -@keyframes new-update-glow { - 0% { - opacity: 1; - transform: scale(1); - }, - 80%, 100% { - opacity: 0; - transform: scale(2.5); - } -} - -.Version { - position: relative; - - &-text { - &.has-update:hover { - cursor: pointer; - text-decoration: underline; - } - } - - &-new { - position: absolute; - top: 0px; - left: -12px; - width: 8px; - height: 8px; - border-radius: 100%; - background: $brand-warning; - animation: new-update-popin 500ms ease 1; - - &:before { - content: ''; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - border-radius: 100%; - box-shadow: 0 0 3px $brand-warning; - animation: new-update-glow 1200ms ease infinite; - } - } -} diff --git a/common/components/Header/components/Version.tsx b/common/components/Header/components/Version.tsx index ea6c92c7..f50feba8 100644 --- a/common/components/Header/components/Version.tsx +++ b/common/components/Header/components/Version.tsx @@ -1,48 +1,6 @@ import React from 'react'; import { VERSION } from 'config/data'; -import UpdateModal, { UpdateInfo } from 'components/UpdateModal'; -import { addListener } from 'utils/electron'; -import EVENTS from 'shared/electronEvents'; -import './Version.scss'; -interface State { - updateInfo: UpdateInfo | null; - isModalOpen: boolean; -} +const Version: React.SFC<{}> = () =>
v{VERSION}
; -export default class Version extends React.Component<{}, State> { - public state: State = { - updateInfo: null, - isModalOpen: false - }; - - public componentDidMount() { - addListener(EVENTS.UPDATE.UPDATE_AVAILABLE, updateInfo => { - this.setState({ updateInfo }); - }); - } - - public render() { - const { updateInfo, isModalOpen } = this.state; - return ( -
- - v{VERSION} - - {updateInfo && ( - - - - - )} -
- ); - } - - private openModal = () => this.setState({ isModalOpen: true }); - private closeModal = () => this.setState({ isModalOpen: false }); -} +export default Version; diff --git a/common/components/UpdateModal.scss b/common/components/UpdateModal.scss deleted file mode 100644 index c4eb8f16..00000000 --- a/common/components/UpdateModal.scss +++ /dev/null @@ -1,62 +0,0 @@ -@import 'common/sass/variables'; - -.UpdateModal { - cursor: default; - @media (min-width: 680px) { - min-width: 680px; - } - - &-title { - margin-top: 0; - margin-bottom: $space-xs; - } - - &-date { - font-size: $font-size-small; - color: $gray; - padding-bottom: $space-sm; - border-bottom: 1px solid $gray-lighter; - } - - &-downloader { - padding: 50px 30px 80px; - text-align: center; - - &-bar { - position: relative; - margin: 0 20px; - height: 16px; - border-radius: 4px; - background: $gray-lighter; - margin-bottom: $space-sm; - - &-inner { - position: absolute; - top: 0; - left: 0; - bottom: 0; - border-radius: 4px; - background: $brand-primary; - transition: width 100ms ease; - } - } - - &-info { - color: $gray-light; - font-size: $font-size-xs; - - &-bit { - &:after { - display: inline-block; - content: "•"; - padding: 0 $space-xs; - opacity: 0.8; - } - - &:last-child:after { - display: none; - } - } - } - } -} diff --git a/common/components/UpdateModal.tsx b/common/components/UpdateModal.tsx deleted file mode 100644 index d5855b91..00000000 --- a/common/components/UpdateModal.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import React from 'react'; -import { connect } from 'react-redux'; -import moment from 'moment'; -import { showNotification, TShowNotification } from 'actions/notifications'; -import { Spinner, NewTabLink } from 'components/ui'; -import Modal, { IButton } from 'components/ui/Modal'; -import { addListener, sendEvent } from 'utils/electron'; -import EVENTS from 'shared/electronEvents'; -import { bytesToHuman } from 'utils/formatters'; -import './UpdateModal.scss'; - -export interface UpdateInfo { - version: string; - sha512: string; - releaseDate: string; - releaseName: string; - releaseNotes: string; -} - -export interface DownloadProgress { - bytesPerSecond: number; - percent: number; - transferred: number; - total: number; -} - -interface Props { - isOpen: boolean; - updateInfo: UpdateInfo; - showNotification: TShowNotification; - handleClose(): void; -} - -interface State { - isDownloading: boolean; - downloadProgress: DownloadProgress | null; -} - -class UpdateModal extends React.Component { - public state: State = { - isDownloading: false, - downloadProgress: null - }; - - public componentDidMount() { - addListener(EVENTS.UPDATE.UPDATE_DOWNLOADED, () => { - sendEvent(EVENTS.UPDATE.QUIT_AND_INSTALL); - }); - addListener(EVENTS.UPDATE.DOWNLOAD_PROGRESS, downloadProgress => { - this.setState({ downloadProgress }); - }); - addListener(EVENTS.UPDATE.ERROR, err => { - console.error('Update failed:', err); - this.setState({ isDownloading: false }); - this.props.showNotification( - 'danger', - - Update could not be downloaded, please visit{' '} - - our github - {' '} - to download the latest release - , - Infinity - ); - }); - } - - public render() { - const { isOpen, updateInfo, handleClose } = this.props; - const { isDownloading, downloadProgress } = this.state; - const buttons: IButton[] | undefined = downloadProgress - ? undefined - : [ - { - text: {isDownloading && } Download Update, - type: 'primary', - onClick: this.downloadUpdate, - disabled: isDownloading - }, - { - text: 'Close', - type: 'default', - onClick: handleClose - } - ]; - - return ( - -
- {downloadProgress ? ( -
-

Downloading...

-
-
-
-
- - Downloaded {downloadProgress.percent.toFixed(1)}% - - - {bytesToHuman(downloadProgress.bytesPerSecond)}/s - - - Total Size {bytesToHuman(downloadProgress.total)} - -
-
- ) : ( -
-

{updateInfo.releaseName}

-
{moment(updateInfo.releaseDate).format('LL')}
-
-
- )} -
- - ); - } - - private downloadUpdate = () => { - this.setState({ isDownloading: true }); - sendEvent('UPDATE:download-update'); - }; -} - -export default connect(undefined, { showNotification })(UpdateModal); diff --git a/common/components/ui/NewTabLink.tsx b/common/components/ui/NewTabLink.tsx index 95fb67dd..f011aaa6 100644 --- a/common/components/ui/NewTabLink.tsx +++ b/common/components/ui/NewTabLink.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { openInBrowser } from 'utils/electron'; interface AAttributes { charset?: string; @@ -40,17 +39,11 @@ export class NewTabLink extends React.Component { public render() { const { content, children, ...rest } = this.props; return ( - + {content || children} ); } - - private handleClick(ev: React.MouseEvent) { - if (openInBrowser(ev.currentTarget.href)) { - ev.preventDefault(); - } - } } export default NewTabLink; diff --git a/common/utils/electron.ts b/common/utils/electron.ts deleted file mode 100644 index 0e09b0c2..00000000 --- a/common/utils/electron.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Handles integrations with Electron. Wherever possible, should stub out -// behavior with noop's if not in the Electron environment, to simplify code. -import { ElectronBridgeFunctions } from 'shared/electronBridge'; -const bridge: ElectronBridgeFunctions | null = (window as any).electronBridge; - -export const addListener: ElectronBridgeFunctions['addListener'] = (event, cb) => { - if (bridge && bridge.addListener) { - // @ts-ignore unused ev - bridge.addListener(event, (ev, data) => cb(data)); - } -}; - -export const sendEvent: ElectronBridgeFunctions['sendEvent'] = (event, data) => { - if (bridge && bridge.sendEvent) { - bridge.sendEvent(event, data); - } -}; - -export const openInBrowser: ElectronBridgeFunctions['openInBrowser'] = url => { - if (bridge && bridge.openInBrowser) { - bridge.openInBrowser(url); - return true; - } - return false; -}; diff --git a/electron-app/main/index.ts b/electron-app/main/index.ts index 6be98398..c0234a3b 100644 --- a/electron-app/main/index.ts +++ b/electron-app/main/index.ts @@ -1,63 +1,5 @@ -import { app, BrowserWindow, Menu } from 'electron'; -import * as path from 'path'; -import updater from './updater'; -import MENU from './menu'; - -const isDevelopment = process.env.NODE_ENV !== 'production'; - -// Global reference to mainWindow -// Necessary to prevent window from being garbage collected -let mainWindow; - -function createMainWindow() { - // Construct new BrowserWindow - const window = new BrowserWindow({ - title: 'MyEtherWallet', - backgroundColor: '#fbfbfb', - width: 1220, - height: 800, - minWidth: 320, - minHeight: 400, - // TODO - Implement styles for custom title bar in components/ui/TitleBar.scss - // frame: false, - // titleBarStyle: 'hidden', - webPreferences: { - devTools: true, - nodeIntegration: false, - preload: path.resolve(__dirname, 'preload.js') - } - }); - - const url = isDevelopment - ? `http://localhost:${process.env.HTTPS ? 3443 : 3000}` - : `file://${__dirname}/index.html`; - window.loadURL(url); - - window.on('closed', () => { - mainWindow = null; - }); - - window.webContents.on('devtools-opened', () => { - window.focus(); - setImmediate(() => { - window.focus(); - }); - }); - - if (isDevelopment) { - window.webContents.on('did-fail-load', () => { - setTimeout(() => { - if (window && window.webContents) { - window.webContents.reload(); - } - }, 500); - }); - } - - Menu.setApplicationMenu(Menu.buildFromTemplate(MENU)); - - return window; -} +import { app } from 'electron'; +import getWindow from './window'; // Quit application when all windows are closed app.on('window-all-closed', () => { @@ -71,16 +13,10 @@ app.on('window-all-closed', () => { app.on('activate', () => { // On macOS it is common to re-create a window // even after all windows have been closed - if (mainWindow === null) { - mainWindow = createMainWindow(); - updater(app, mainWindow); - } + getWindow(); }); // Create main BrowserWindow when electron is ready app.on('ready', () => { - mainWindow = createMainWindow(); - mainWindow.webContents.on('did-finish-load', () => { - updater(app, mainWindow); - }); + getWindow(); }); diff --git a/electron-app/main/updater.ts b/electron-app/main/updater.ts index 1f0d09bb..c08c264c 100644 --- a/electron-app/main/updater.ts +++ b/electron-app/main/updater.ts @@ -1,97 +1,144 @@ -import { App, BrowserWindow, ipcMain } from 'electron'; -import { autoUpdater } from 'electron-updater'; -import EVENTS from '../../shared/electronEvents'; +import { app, dialog, BrowserWindow } from 'electron'; +import { autoUpdater, UpdateInfo } from 'electron-updater'; import TEST_RELEASE from './testrelease.json'; autoUpdater.autoDownload = false; +// Set to 'true' if you want to test update behavior. Requires a recompile. +const shouldMockUpdate = false && process.env.NODE_ENV !== 'production'; +const shouldMockUpdateError = false && process.env.NODE_ENV !== 'production'; +let hasRunUpdater = false; +let hasStartedUpdating = false; + enum AutoUpdaterEvents { CHECKING_FOR_UPDATE = 'checking-for-update', - UPDATE_NOT_AVAILABLE = 'update-not-available', UPDATE_AVAILABLE = 'update-available', DOWNLOAD_PROGRESS = 'download-progress', UPDATE_DOWNLOADED = 'update-downloaded', ERROR = 'error' } -export default (app: App, window: BrowserWindow) => { - // Set to 'true' if you want to test update behavior. Requires a recompile. - const shouldMockUpdate = true && process.env.NODE_ENV !== 'production'; +export default function(mainWindow: BrowserWindow) { + if (hasRunUpdater) { + return; + } + hasRunUpdater = true; - // Report update status - autoUpdater.on(AutoUpdaterEvents.CHECKING_FOR_UPDATE, () => { - window.webContents.send(EVENTS.UPDATE.CHECKING_FOR_UPDATE); - }); - - autoUpdater.on(AutoUpdaterEvents.UPDATE_NOT_AVAILABLE, () => { - window.webContents.send(EVENTS.UPDATE.UPDATE_NOT_AVAILABLE); - }); - - autoUpdater.on(AutoUpdaterEvents.UPDATE_AVAILABLE, info => { - window.webContents.send(EVENTS.UPDATE.UPDATE_AVAILABLE, info); + autoUpdater.on(AutoUpdaterEvents.UPDATE_AVAILABLE, (info: UpdateInfo) => { + dialog.showMessageBox( + { + type: 'question', + buttons: ['Yes, start downloading', 'Maybe later'], + title: `An Update is Available (v${info.version})`, + message: `An Update is Available (v${info.version})`, + detail: + 'A new version has been released. Would you like to start downloading the update? You will be notified when the download is finished.' + }, + response => { + if (response === 0) { + if (shouldMockUpdate) { + mockDownload(); + } else { + autoUpdater.downloadUpdate(); + } + } + } + ); + hasStartedUpdating = true; }); autoUpdater.on(AutoUpdaterEvents.DOWNLOAD_PROGRESS, progress => { - window.webContents.send(EVENTS.UPDATE.DOWNLOAD_PROGRESS, progress); + mainWindow.setTitle(`MyEtherWallet (Downloading update... ${Math.round(progress.percent)}%)`); + mainWindow.setProgressBar(progress.percent / 100); }); autoUpdater.on(AutoUpdaterEvents.UPDATE_DOWNLOADED, () => { - window.webContents.send(EVENTS.UPDATE.UPDATE_DOWNLOADED); + resetWindowFromUpdates(mainWindow); + dialog.showMessageBox( + { + type: 'question', + buttons: ['Yes, restart now', 'Maybe later'], + title: 'Update Has Been Downloaded', + message: 'Download complete!', + detail: + 'The new version of MyEtherWallet has finished downloading. Would you like to restart to complete the installation?' + }, + response => { + if (response === 0) { + if (shouldMockUpdate) { + app.quit(); + } else { + autoUpdater.quitAndInstall(); + } + } + } + ); }); - autoUpdater.on(AutoUpdaterEvents.ERROR, (err, msg) => { + autoUpdater.on(AutoUpdaterEvents.ERROR, (err: Error) => { console.error('Update failed with an error'); console.error(err); - window.webContents.send(EVENTS.UPDATE.ERROR, msg); + + // If they haven't started updating yet, just fail silently + if (!hasStartedUpdating) { + return; + } + + resetWindowFromUpdates(mainWindow); + dialog.showErrorBox( + 'Downloading Update has Failed', + `The update could not be downloaded. Restart the app and try again later, or manually install the new update at https://github.com/MyEtherWallet/MyEtherWallet/releases\n\n(${ + err.name + }: ${err.message})` + ); }); + // Kick off the check autoUpdater.checkForUpdatesAndNotify(); - // Listen for restart request - ipcMain.on(EVENTS.UPDATE.DOWNLOAD_UPDATE, () => { - if (shouldMockUpdate) { - mockDownload(window); - } else { - autoUpdater.downloadUpdate(); - } - }); - - ipcMain.on(EVENTS.UPDATE.QUIT_AND_INSTALL, () => { - if (shouldMockUpdate) { - app.quit(); - } else { - autoUpdater.quitAndInstall(); - } - }); - // Simulate a test release if (shouldMockUpdate) { - mockUpdateCheck(window); + mockUpdateCheck(); } -}; +} + +function resetWindowFromUpdates(window: BrowserWindow) { + window.setTitle('MyEtherWallet'); + window.setProgressBar(-1); // Clears progress bar +} // Mock functions for dev testing -function mockUpdateCheck(window: BrowserWindow) { - window.webContents.send(EVENTS.UPDATE.CHECKING_FOR_UPDATE); +function mockUpdateCheck() { + autoUpdater.emit(AutoUpdaterEvents.CHECKING_FOR_UPDATE); setTimeout(() => { - window.webContents.send(EVENTS.UPDATE.UPDATE_AVAILABLE, TEST_RELEASE); + autoUpdater.emit(AutoUpdaterEvents.UPDATE_AVAILABLE, TEST_RELEASE); }, 3000); } -function mockDownload(window: BrowserWindow) { - for (let i = 0; i < 101; i++) { +function mockDownload() { + for (let i = 0; i < 11; i++) { setTimeout(() => { + if (i >= 5 && shouldMockUpdateError) { + if (i === 5) { + autoUpdater.emit( + AutoUpdaterEvents.ERROR, + new Error('Test error, nothing actually failed') + ); + } + return; + } + const total = 150000000; - window.webContents.send(EVENTS.UPDATE.DOWNLOAD_PROGRESS, { - bytesPerSecond: Math.round(Math.random() * 100000), - percent: i, + autoUpdater.emit(AutoUpdaterEvents.DOWNLOAD_PROGRESS, { + bytesPerSecond: Math.round(Math.random() * 100000000), + percent: i * 10, transferred: total / i, total }); - if (i === 100) { - window.webContents.send(EVENTS.UPDATE.UPDATE_DOWNLOADED); + if (i === 10) { + autoUpdater.emit(AutoUpdaterEvents.UPDATE_DOWNLOADED); } - }, 50 * i); + }, 500 * i); } } diff --git a/electron-app/main/window.ts b/electron-app/main/window.ts new file mode 100644 index 00000000..bcca6b0f --- /dev/null +++ b/electron-app/main/window.ts @@ -0,0 +1,78 @@ +import { BrowserWindow, Menu, shell } from 'electron'; +import { URL } from 'url'; +import MENU from './menu'; +import updater from './updater'; +const isDevelopment = process.env.NODE_ENV !== 'production'; + +// Cached reference, preventing recreations +let window; + +// Construct new BrowserWindow +export default function getWindow() { + if (window) { + return window; + } + + window = new BrowserWindow({ + title: 'MyEtherWallet', + backgroundColor: '#fbfbfb', + width: 1220, + height: 800, + minWidth: 320, + minHeight: 400, + // TODO - Implement styles for custom title bar in components/ui/TitleBar.scss + // frame: false, + // titleBarStyle: 'hidden', + webPreferences: { + devTools: true, + nodeIntegration: false, + contextIsolation: true + } + }); + + const port = process.env.HTTPS ? '3443' : '3000'; + const appUrl = isDevelopment ? `http://localhost:${port}` : `file://${__dirname}/index.html`; + window.loadURL(appUrl); + + window.on('closed', () => { + window = null; + }); + + window.webContents.on('new-window', (ev, urlStr) => { + // Kill all new window requests by default + ev.preventDefault(); + + // Only allow HTTPS urls to actually be opened + const url = new URL(urlStr); + if (url.protocol === 'https:') { + shell.openExternal(urlStr); + } else { + console.warn(`Blocked request to open new window '${urlStr}', only HTTPS links are allowed`); + } + }); + + window.webContents.on('did-finish-load', () => { + updater(window); + }); + + window.webContents.on('devtools-opened', () => { + window.focus(); + setImmediate(() => { + window.focus(); + }); + }); + + if (isDevelopment) { + window.webContents.on('did-fail-load', () => { + setTimeout(() => { + if (window && window.webContents) { + window.webContents.reload(); + } + }, 500); + }); + } + + Menu.setApplicationMenu(Menu.buildFromTemplate(MENU)); + + return window; +} diff --git a/electron-app/preload.ts b/electron-app/preload.ts deleted file mode 100644 index da9fd22c..00000000 --- a/electron-app/preload.ts +++ /dev/null @@ -1,19 +0,0 @@ -// Selectively expose node integration, since all node integrations are -// disabled by default for security purposes. -import { ipcRenderer, shell } from 'electron'; -import { ElectronBridgeFunctions } from '../shared/electronBridge'; -const win = window as any; - -const functions: ElectronBridgeFunctions = { - addListener(event, cb) { - ipcRenderer.on(event, cb); - }, - sendEvent(event, data) { - ipcRenderer.send(event, data); - }, - openInBrowser(url) { - return shell.openExternal(url); - } -}; - -win.electronBridge = functions; diff --git a/jest_config/jest.config.json b/jest_config/jest.config.json index 91ddb83f..b93875a1 100644 --- a/jest_config/jest.config.json +++ b/jest_config/jest.config.json @@ -10,8 +10,7 @@ "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/jest_config/__mocks__/fileMock.ts", "\\.(css|scss|less)$": "/jest_config/__mocks__/styleMock.ts", - "\\.worker.ts":"/jest_config/__mocks__/workerMock.js", - "^shared(.*)$": "/shared$1" + "\\.worker.ts":"/jest_config/__mocks__/workerMock.js" }, "testPathIgnorePatterns": ["/common/config"], "setupFiles": [ diff --git a/jest_config/jest.int.config.json b/jest_config/jest.int.config.json index f2be1359..404dce04 100644 --- a/jest_config/jest.int.config.json +++ b/jest_config/jest.int.config.json @@ -9,8 +9,7 @@ "moduleNameMapper": { "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/jest_config/__mocks__/fileMock.ts", - "\\.(css|scss|less)$": "/jest_config/__mocks__/styleMock.ts", - "^shared(.*)$": "/shared$1" + "\\.(css|scss|less)$": "/jest_config/__mocks__/styleMock.ts" }, "testPathIgnorePatterns": ["/common/config"], "setupFiles": [ diff --git a/shared/electronBridge.ts b/shared/electronBridge.ts deleted file mode 100644 index f24582f4..00000000 --- a/shared/electronBridge.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Provide typescript definitions / mappings between `electron-app/preload.ts` -// and 'common/utils/electron.ts' -export type ElectronBridgeCallback = (data?: any) => void; - -export interface ElectronBridgeFunctions { - addListener(event: string, cb: ElectronBridgeCallback); - sendEvent(event: string, data?: any); - openInBrowser(url: string): boolean; -} diff --git a/shared/electronEvents.ts b/shared/electronEvents.ts deleted file mode 100644 index 60fb2fd6..00000000 --- a/shared/electronEvents.ts +++ /dev/null @@ -1,12 +0,0 @@ -export default { - UPDATE: { - CHECKING_FOR_UPDATE: 'UPDATE:checking-for-update', - UPDATE_NOT_AVAILABLE: 'UPDATE:update-not-available', - UPDATE_AVAILABLE: 'UPDATE:update-available', - DOWNLOAD_PROGRESS: 'UPDATE:download-progress', - UPDATE_DOWNLOADED: 'UPDATE:update-downloaded', - ERROR: 'UPDATE:error', - DOWNLOAD_UPDATE: 'UPDATE:download-update', - QUIT_AND_INSTALL: 'UPDATE:quit-and-install' - } -}; diff --git a/tsconfig.json b/tsconfig.json index 8f948409..0547c07f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,7 +23,7 @@ }, "include": [ "./common/", - "./electron/", + "./electron-app/", "spec", "./node_modules/types-rlp/index.d.ts" ], diff --git a/webpack_config/webpack.electron-dev.js b/webpack_config/webpack.electron-dev.js index ff1a4525..bf96ef29 100644 --- a/webpack_config/webpack.electron-dev.js +++ b/webpack_config/webpack.electron-dev.js @@ -8,8 +8,7 @@ const makeConfig = require('./makeConfig'); const electronConfig = { target: 'electron-main', entry: { - main: path.join(config.path.electron, 'main/index.ts'), - preload: path.join(config.path.electron, 'preload.ts') + main: path.join(config.path.electron, 'main/index.ts') }, module: { rules: [config.typescriptRule] From 2309d05c06e9885c0696b109aa6d2ce719e5c06c Mon Sep 17 00:00:00 2001 From: William O'Beirne Date: Fri, 26 Jan 2018 15:08:39 -0500 Subject: [PATCH 02/72] Disable more wallets conditionally + explain why (#924) * Add more wallet disables, move all into selector. * Add reasons for disabled wallets. * Disable read only in lite send. * Fix view address showing insecure icon. --- .../WalletDecrypt/WalletDecrypt.tsx | 40 ++++---- .../components/WalletButton.scss | 21 ++-- .../WalletDecrypt/components/WalletButton.tsx | 95 ++++++++++++------- common/components/WalletDecrypt/disables.ts | 32 +++++-- common/components/WalletDecrypt/index.tsx | 2 +- common/components/ui/Tooltip.scss | 41 +++++++- common/components/ui/Tooltip.tsx | 11 ++- common/components/ui/UnlockHeader.tsx | 5 +- .../Swap/components/LiteSend/LiteSend.tsx | 8 +- common/selectors/wallet.ts | 59 +++++++++++- 10 files changed, 227 insertions(+), 87 deletions(-) diff --git a/common/components/WalletDecrypt/WalletDecrypt.tsx b/common/components/WalletDecrypt/WalletDecrypt.tsx index d0a330c9..5146ad47 100644 --- a/common/components/WalletDecrypt/WalletDecrypt.tsx +++ b/common/components/WalletDecrypt/WalletDecrypt.tsx @@ -32,8 +32,9 @@ import { InsecureWalletWarning } from './components'; import { AppState } from 'reducers'; -import DISABLES from './disables'; import { showNotification, TShowNotification } from 'actions/notifications'; +import { getDisabledWallets } from 'selectors/wallet'; +import { DisabledWallets } from './disables'; import LedgerIcon from 'assets/images/wallets/ledger.svg'; import MetamaskIcon from 'assets/images/wallets/metamask.svg'; @@ -48,12 +49,10 @@ import { isWeb3NodeAvailable, knowledgeBaseURL } from 'config'; -import { unSupportedWalletFormatsOnNetwork } from 'utils/network'; -import { getNetworkConfig } from '../../selectors/config'; interface OwnProps { hidden?: boolean; - disabledWallets?: WalletName[]; + disabledWallets?: DisabledWallets; showGenerateLink?: boolean; } @@ -69,8 +68,7 @@ interface DispatchProps { } interface StateProps { - computedDisabledWallets: WalletName[]; - offline: boolean; + computedDisabledWallets: DisabledWallets; isWalletPending: AppState['wallet']['isWalletPending']; isPasswordPending: AppState['wallet']['isPasswordPending']; } @@ -282,6 +280,9 @@ export class WalletDecrypt extends Component { }; public buildWalletOptions() { + const { computedDisabledWallets } = this.props; + const { reasons } = computedDisabledWallets; + return (

{translate('decrypt_Access')}

@@ -299,6 +300,7 @@ export class WalletDecrypt extends Component { walletType={walletType} isSecure={true} isDisabled={this.isWalletDisabled(walletType)} + disableReason={reasons[walletType]} onClick={this.handleWalletChoice} /> ); @@ -316,6 +318,7 @@ export class WalletDecrypt extends Component { walletType={walletType} isSecure={false} isDisabled={this.isWalletDisabled(walletType)} + disableReason={reasons[walletType]} onClick={this.handleWalletChoice} /> ); @@ -332,6 +335,7 @@ export class WalletDecrypt extends Component { walletType={walletType} isReadOnly={true} isDisabled={this.isWalletDisabled(walletType)} + disableReason={reasons[walletType]} onClick={this.handleWalletChoice} /> ); @@ -426,24 +430,26 @@ export class WalletDecrypt extends Component { }; private isWalletDisabled = (walletKey: WalletName) => { - if (this.props.offline && DISABLES.ONLINE_ONLY.includes(walletKey)) { - return true; - } - - return this.props.computedDisabledWallets.indexOf(walletKey) !== -1; + return this.props.computedDisabledWallets.wallets.indexOf(walletKey) !== -1; }; } function mapStateToProps(state: AppState, ownProps: Props) { const { disabledWallets } = ownProps; - const network = getNetworkConfig(state); - const networkDisabledFormats = unSupportedWalletFormatsOnNetwork(network); - const computedDisabledWallets = disabledWallets - ? disabledWallets.concat(networkDisabledFormats) - : networkDisabledFormats; + let computedDisabledWallets = getDisabledWallets(state); + + if (disabledWallets) { + computedDisabledWallets = { + wallets: [...computedDisabledWallets.wallets, ...disabledWallets.wallets], + reasons: { + ...computedDisabledWallets.reasons, + ...disabledWallets.reasons + } + }; + } + return { computedDisabledWallets, - offline: state.config.offline, isWalletPending: state.wallet.isWalletPending, isPasswordPending: state.wallet.isPasswordPending }; diff --git a/common/components/WalletDecrypt/components/WalletButton.scss b/common/components/WalletDecrypt/components/WalletButton.scss index 2008d01a..615d2090 100644 --- a/common/components/WalletDecrypt/components/WalletButton.scss +++ b/common/components/WalletDecrypt/components/WalletButton.scss @@ -12,16 +12,6 @@ } } -@keyframes wallet-button-enter-disabled { - 0% { - opacity: 0; - } - 30%, - 100% { - opacity: 0.3; - } -} - .WalletButton { position: relative; flex: 1; @@ -70,10 +60,17 @@ } &.is-disabled { - opacity: 0.3 !important; outline: none; cursor: not-allowed; - animation-name: wallet-button-enter, wallet-button-enter-disabled; + @include show-tooltip-on-hover; + + .WalletButton-inner { + opacity: 0.3; + } + } + + &-inner { + transition: opacity 200ms ease; } &-title { diff --git a/common/components/WalletDecrypt/components/WalletButton.tsx b/common/components/WalletDecrypt/components/WalletButton.tsx index 33b01dee..7932ea9a 100644 --- a/common/components/WalletDecrypt/components/WalletButton.tsx +++ b/common/components/WalletDecrypt/components/WalletButton.tsx @@ -16,6 +16,7 @@ interface OwnProps { isSecure?: boolean; isReadOnly?: boolean; isDisabled?: boolean; + disableReason?: string; onClick(walletType: string): void; } @@ -23,6 +24,12 @@ interface StateProps { isFormatDisabled?: boolean; } +interface Icon { + icon: string; + tooltip: string; + href?: string; +} + type Props = OwnProps & StateProps; export class WalletButton extends React.PureComponent { @@ -35,9 +42,37 @@ export class WalletButton extends React.PureComponent { helpLink, isSecure, isReadOnly, - isDisabled + isDisabled, + disableReason } = this.props; + const icons: Icon[] = []; + if (isReadOnly) { + icons.push({ + icon: 'eye', + tooltip: translateRaw('You cannot send using address only') + }); + } else { + if (isSecure) { + icons.push({ + icon: 'shield', + tooltip: translateRaw('This wallet type is secure') + }); + } else { + icons.push({ + icon: 'exclamation-triangle', + tooltip: translateRaw('This wallet type is insecure') + }); + } + } + if (helpLink) { + icons.push({ + icon: 'question-circle', + tooltip: translateRaw('NAV_Help'), + href: helpLink + }); + } + return (
{ tabIndex={isDisabled ? -1 : 0} aria-disabled={isDisabled} > -
- {icon && } - {name} +
+
+ {icon && } + {name} +
+ + {description &&
{description}
} + {example &&
{example}
} + +
+ {icons.map(i => ( + + {i.href ? ( + + + + ) : ( + + )} + {!isDisabled && {i.tooltip}} + + ))} +
- {description &&
{description}
} - {example &&
{example}
} - -
- {isSecure ? ( - - - {translateRaw('This wallet type is secure')} - - ) : ( - - - {translateRaw('This wallet type is insecure')} - - )} - {isReadOnly && ( - - - {translateRaw('You cannot send using address only')} - - )} - - {helpLink && ( - - - - - {translateRaw('NAV_Help')} - - )} -
+ {isDisabled && disableReason && {disableReason}}
); } diff --git a/common/components/WalletDecrypt/disables.ts b/common/components/WalletDecrypt/disables.ts index 2a7fcbb1..3ed1fde3 100644 --- a/common/components/WalletDecrypt/disables.ts +++ b/common/components/WalletDecrypt/disables.ts @@ -1,15 +1,31 @@ import { MiscWalletName, SecureWalletName, WalletName } from 'config'; +export interface DisabledWallets { + wallets: WalletName[]; + reasons: { + [key: string]: string; + }; +} + enum WalletMode { READ_ONLY = 'READ_ONLY', - UNABLE_TO_SIGN = 'UNABLE_TO_SIGN', - ONLINE_ONLY = 'ONLINE_ONLY' + UNABLE_TO_SIGN = 'UNABLE_TO_SIGN' } -const walletModes: { [key in WalletMode]: WalletName[] } = { - [WalletMode.READ_ONLY]: [MiscWalletName.VIEW_ONLY], - [WalletMode.UNABLE_TO_SIGN]: [SecureWalletName.TREZOR, MiscWalletName.VIEW_ONLY], - [WalletMode.ONLINE_ONLY]: [SecureWalletName.WEB3, SecureWalletName.TREZOR] +// Duplicating reasons is kind of tedious, but saves having to run through a +// bunch of loops to format it differently +export const DISABLE_WALLETS: { [key in WalletMode]: DisabledWallets } = { + [WalletMode.READ_ONLY]: { + wallets: [MiscWalletName.VIEW_ONLY], + reasons: { + [MiscWalletName.VIEW_ONLY]: 'Read only is not allowed' + } + }, + [WalletMode.UNABLE_TO_SIGN]: { + wallets: [SecureWalletName.TREZOR, MiscWalletName.VIEW_ONLY], + reasons: { + [SecureWalletName.TREZOR]: 'This wallet can’t sign messages', + [MiscWalletName.VIEW_ONLY]: 'This wallet can’t sign messages' + } + } }; - -export default walletModes; diff --git a/common/components/WalletDecrypt/index.tsx b/common/components/WalletDecrypt/index.tsx index 3fc3ad75..916296ae 100644 --- a/common/components/WalletDecrypt/index.tsx +++ b/common/components/WalletDecrypt/index.tsx @@ -1,3 +1,3 @@ import WalletDecrypt from './WalletDecrypt'; export default WalletDecrypt; -export { default as DISABLE_WALLETS } from './disables'; +export * from './disables'; diff --git a/common/components/ui/Tooltip.scss b/common/components/ui/Tooltip.scss index af6d0bf3..e6033a23 100644 --- a/common/components/ui/Tooltip.scss +++ b/common/components/ui/Tooltip.scss @@ -1,13 +1,15 @@ @import 'common/sass/variables'; @import 'common/sass/mixins'; +$tooltip-bg: rgba(#222, 0.95); + .Tooltip { position: absolute; top: 0; left: 50%; width: 220px; color: #FFF; - font-size: $font-size-xs; + font-size: $font-size-small; font-family: $font-family-sans-serif; pointer-events: none; opacity: 0; @@ -20,9 +22,9 @@ > span { display: inline-block; - background: rgba(#000, 0.9); - border-radius: 2px; - padding: 4px 8px; + background: $tooltip-bg; + border-radius: 3px; + padding: 6px 10px; &:after { position: absolute; @@ -30,7 +32,36 @@ bottom: 0; left: 50%; transform: translate(-50%, 100%); - @include triangle(8px, rgba(#000, 0.9), down); + @include triangle(10px, $tooltip-bg, down); + } + } + + // Sizing, medium is default + &.is-size-sm { + width: 200px; + font-size: $font-size-xs; + + > span { + padding: 4px 8px; + border-radius: 2px; + + &:after { + @include triangle(8px, $tooltip-bg, down); + } + } + } + + &.is-size-lg { + width: 240px; + font-size: $font-size-base; + + > span { + padding: 8px 12px; + border-radius: 4px; + + &:after { + @include triangle(12px, $tooltip-bg, down); + } } } } diff --git a/common/components/ui/Tooltip.tsx b/common/components/ui/Tooltip.tsx index 4ab4fa16..6698f741 100644 --- a/common/components/ui/Tooltip.tsx +++ b/common/components/ui/Tooltip.tsx @@ -1,12 +1,19 @@ import React from 'react'; +import classnames from 'classnames'; import './Tooltip.scss'; interface Props { children: React.ReactElement | string; + size?: 'sm' | 'md' | 'lg'; } -const Tooltip: React.SFC = ({ children }) => ( -
+const Tooltip: React.SFC = ({ size, children }) => ( +
{children}
); diff --git a/common/components/ui/UnlockHeader.tsx b/common/components/ui/UnlockHeader.tsx index ddc700de..09a38355 100644 --- a/common/components/ui/UnlockHeader.tsx +++ b/common/components/ui/UnlockHeader.tsx @@ -2,15 +2,14 @@ import React from 'react'; import { connect } from 'react-redux'; import { AppState } from 'reducers'; import translate, { TranslateType } from 'translations'; -import WalletDecrypt from 'components/WalletDecrypt'; +import WalletDecrypt, { DisabledWallets } from 'components/WalletDecrypt'; import { IWallet } from 'libs/wallet/IWallet'; import './UnlockHeader.scss'; -import { WalletName } from 'config'; interface Props { title: TranslateType; wallet: IWallet; - disabledWallets?: WalletName[]; + disabledWallets?: DisabledWallets; showGenerateLink?: boolean; } diff --git a/common/containers/Tabs/Swap/components/LiteSend/LiteSend.tsx b/common/containers/Tabs/Swap/components/LiteSend/LiteSend.tsx index 08491a93..d3058a8e 100644 --- a/common/containers/Tabs/Swap/components/LiteSend/LiteSend.tsx +++ b/common/containers/Tabs/Swap/components/LiteSend/LiteSend.tsx @@ -1,5 +1,5 @@ import React, { Component } from 'react'; -import WalletDecrypt from 'components/WalletDecrypt'; +import WalletDecrypt, { DISABLE_WALLETS } from 'components/WalletDecrypt'; import { OnlyUnlocked } from 'components/renderCbs'; import { Fields } from './Fields'; import { isUnlocked as isUnlockedSelector } from 'selectors/wallet'; @@ -41,7 +41,11 @@ class LiteSendClass extends Component {
); } else { - renderMe = isUnlocked ? } /> : ; + renderMe = isUnlocked ? ( + } /> + ) : ( + + ); } return {renderMe}; diff --git a/common/selectors/wallet.ts b/common/selectors/wallet.ts index c3347b1d..b8924551 100644 --- a/common/selectors/wallet.ts +++ b/common/selectors/wallet.ts @@ -1,9 +1,11 @@ import { TokenValue, Wei } from 'libs/units'; -import { Token } from 'config'; +import { Token, SecureWalletName, WalletName } from 'config'; import { AppState } from 'reducers'; -import { getNetworkConfig } from 'selectors/config'; +import { getNetworkConfig, getOffline } from 'selectors/config'; import { IWallet, Web3Wallet, LedgerWallet, TrezorWallet, WalletConfig } from 'libs/wallet'; import { isEtherTransaction, getUnit } from './transaction'; +import { unSupportedWalletFormatsOnNetwork } from 'utils/network'; +import { DisabledWallets } from 'components/WalletDecrypt'; export function getWalletInst(state: AppState): IWallet | null | undefined { return state.wallet.inst; @@ -139,3 +141,56 @@ export function getShownTokenBalances( return tokenBalances.filter(t => walletTokens.includes(t.symbol)); } + +// TODO: Convert to reselect selector (Issue #884) +export function getDisabledWallets(state: AppState): DisabledWallets { + const network = getNetworkConfig(state); + const isOffline = getOffline(state); + const disabledWallets: DisabledWallets = { + wallets: [], + reasons: {} + }; + + const addReason = (wallets: WalletName[], reason: string) => { + if (!wallets.length) { + return; + } + + disabledWallets.wallets = disabledWallets.wallets.concat(wallets); + wallets.forEach(wallet => { + disabledWallets.reasons[wallet] = reason; + }); + }; + + // Some wallets don't support some networks + addReason( + unSupportedWalletFormatsOnNetwork(network), + `${network.name} does not support this wallet` + ); + + // Some wallets are unavailable offline + if (isOffline) { + addReason( + [SecureWalletName.WEB3, SecureWalletName.TREZOR], + 'This wallet cannot be accessed offline' + ); + } + + // Some wallets are disabled on certain platforms + if (process.env.BUILD_DOWNLOADABLE) { + addReason( + [SecureWalletName.LEDGER_NANO_S], + 'This wallet is only supported at MyEtherWallet.com' + ); + } + if (process.env.BUILD_ELECTRON) { + addReason([SecureWalletName.WEB3], 'This wallet is not supported in the MyEtherWallet app'); + } + + // Dedupe and sort for consistency + disabledWallets.wallets = disabledWallets.wallets + .filter((name, idx) => disabledWallets.wallets.indexOf(name) === idx) + .sort(); + + return disabledWallets; +} From 2ad766ce993a4368e13c7cdc0367bdff732045ec Mon Sep 17 00:00:00 2001 From: "greenkeeper[bot]" Date: Fri, 26 Jan 2018 14:13:02 -0600 Subject: [PATCH 03/72] chore(package): update @types/react-select to version 1.2.0 (#929) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9855fce4..4c291aef 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "@types/react-redux": "5.0.14", "@types/react-router-dom": "4.2.3", "@types/react-router-redux": "5.0.11", - "@types/react-select": "1.1.1", + "@types/react-select": "1.2.0", "@types/redux-logger": "3.0.5", "@types/redux-promise-middleware": "0.0.9", "@types/uuid": "3.4.3", From 77ddf602cc0a0ea49ea651c3d2f5b9c2d4f1f33d Mon Sep 17 00:00:00 2001 From: "greenkeeper[bot]" Date: Fri, 26 Jan 2018 15:31:49 -0600 Subject: [PATCH 04/72] chore(package): update style-loader to version 0.20.0 (#932) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4c291aef..fa51421e 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,7 @@ "resolve-url-loader": "2.2.1", "rimraf": "2.6.2", "sass-loader": "6.0.6", - "style-loader": "0.19.1", + "style-loader": "0.20.0", "thread-loader": "1.1.2", "ts-jest": "22.0.1", "ts-loader": "3.3.1", From 7fe28886334d1c789a36ce509f20ffa4cd7b71c2 Mon Sep 17 00:00:00 2001 From: William O'Beirne Date: Fri, 26 Jan 2018 17:11:52 -0500 Subject: [PATCH 05/72] Passwordify Private Key & Mnemonic Inputs (#925) * Create reusable password toggle component, convert private key and mnemonic to it. * Convert keystore modal to togglable password. * Remove the restore keystore tab code (defunct). * Replace wallet info with togglable password. Allow for controlled togglable password. * Convert last password component (generate) and cleanup unneeded files and styles. --- common/assets/images/icon-eye-closed.svg | 3 - common/assets/images/icon-eye.svg | 11 -- common/assets/styles/etherwallet-custom.less | 30 +-- .../GenerateKeystoreModal/Input.tsx | 39 ---- .../GenerateKeystoreModal/index.tsx | 45 +---- common/components/TogglablePassword.scss | 13 ++ common/components/TogglablePassword.tsx | 108 +++++++++++ .../WalletDecrypt/components/Mnemonic.tsx | 12 +- .../WalletDecrypt/components/PrivateKey.tsx | 21 ++- common/components/index.ts | 2 + .../components/Keystore/EnterPassword.tsx | 29 ++- .../components/Keystore/PasswordInput.tsx | 46 ----- .../components/KeystoreDetails.scss | 23 --- .../components/KeystoreDetails.tsx | 177 ------------------ .../components/KeystoreInput.tsx | 36 ---- .../RestoreKeystore/components/Template.tsx | 16 -- .../containers/Tabs/RestoreKeystore/index.tsx | 11 -- .../SendTransaction/components/WalletInfo.tsx | 23 +-- common/containers/Tabs/index.ts | 2 - 19 files changed, 171 insertions(+), 476 deletions(-) delete mode 100755 common/assets/images/icon-eye-closed.svg delete mode 100755 common/assets/images/icon-eye.svg delete mode 100644 common/components/GenerateKeystoreModal/Input.tsx create mode 100644 common/components/TogglablePassword.scss create mode 100644 common/components/TogglablePassword.tsx delete mode 100644 common/containers/Tabs/GenerateWallet/components/Keystore/PasswordInput.tsx delete mode 100644 common/containers/Tabs/RestoreKeystore/components/KeystoreDetails.scss delete mode 100644 common/containers/Tabs/RestoreKeystore/components/KeystoreDetails.tsx delete mode 100644 common/containers/Tabs/RestoreKeystore/components/KeystoreInput.tsx delete mode 100644 common/containers/Tabs/RestoreKeystore/components/Template.tsx delete mode 100644 common/containers/Tabs/RestoreKeystore/index.tsx diff --git a/common/assets/images/icon-eye-closed.svg b/common/assets/images/icon-eye-closed.svg deleted file mode 100755 index 96e69c1b..00000000 --- a/common/assets/images/icon-eye-closed.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/common/assets/images/icon-eye.svg b/common/assets/images/icon-eye.svg deleted file mode 100755 index afbc051e..00000000 --- a/common/assets/images/icon-eye.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - diff --git a/common/assets/styles/etherwallet-custom.less b/common/assets/styles/etherwallet-custom.less index 0e10a4de..753059bf 100755 --- a/common/assets/styles/etherwallet-custom.less +++ b/common/assets/styles/etherwallet-custom.less @@ -163,34 +163,6 @@ textarea { } } -input[type="text"] + .eye { - cursor: pointer; - &:before { - content: ""; - width: 20px; - height: 20px; - margin-left: 6px; - margin-right: 6px; - display: inline-block; - vertical-align: middle; - background: url("../images/icon-eye.svg"); - } -} - -input[type="password"] + .eye { - cursor: pointer; - &:before { - content: ""; - width: 20px; - height: 20px; - margin-left: 6px; - margin-right: 6px; - display: inline-block; - vertical-align: middle; - background: url("../images/icon-eye-closed.svg"); - } -} - // collapsable containers .collapse-container { h2, @@ -398,4 +370,4 @@ label small { .ens-response { color: @gray; -} \ No newline at end of file +} diff --git a/common/components/GenerateKeystoreModal/Input.tsx b/common/components/GenerateKeystoreModal/Input.tsx deleted file mode 100644 index 68175c50..00000000 --- a/common/components/GenerateKeystoreModal/Input.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import React from 'react'; -import classnames from 'classnames'; - -interface Props { - isValid: boolean; - isVisible: boolean; - name: string; - value: string; - placeholder: string; - disabled?: boolean; - handleInput(e: React.FormEvent): void; - handleToggle(): void; -} - -const KeystoreInput: React.SFC = ({ - isValid, - isVisible, - handleInput, - name, - value, - placeholder, - disabled, - handleToggle -}) => ( -
- - -
-); - -export default KeystoreInput; diff --git a/common/components/GenerateKeystoreModal/index.tsx b/common/components/GenerateKeystoreModal/index.tsx index 16ca30d2..396b5460 100644 --- a/common/components/GenerateKeystoreModal/index.tsx +++ b/common/components/GenerateKeystoreModal/index.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { generateKeystoreFileInfo, KeystoreFile } from 'utils/keystore'; import Modal from 'components/ui/Modal'; -import Input from './Input'; +import { TogglablePassword } from 'components'; import translate, { translateRaw } from 'translations'; import { MINIMUM_PASSWORD_LENGTH } from 'config'; import { isValidPrivKey } from 'libs/validators'; @@ -16,8 +16,6 @@ interface Props { interface State { privateKey: string; password: string; - isPrivateKeyVisible: boolean; - isPasswordVisible: boolean; keystoreFile: KeystoreFile | null; hasError: boolean; } @@ -25,8 +23,6 @@ interface State { const initialState: State = { privateKey: '', password: '', - isPrivateKeyVisible: false, - isPasswordVisible: false, keystoreFile: null, hasError: false }; @@ -52,14 +48,7 @@ export default class GenerateKeystoreModal extends React.Component } public render() { - const { - privateKey, - password, - isPrivateKeyVisible, - isPasswordVisible, - keystoreFile, - hasError - } = this.state; + const { privateKey, password, keystoreFile, hasError } = this.state; const isPrivateKeyValid = isValidPrivKey(privateKey); const isPasswordValid = password.length >= MINIMUM_PASSWORD_LENGTH; @@ -73,27 +62,23 @@ export default class GenerateKeystoreModal extends React.Component
@@ -127,18 +112,6 @@ export default class GenerateKeystoreModal extends React.Component ); } - private togglePrivateKey = () => { - this.setState({ - isPrivateKeyVisible: !this.state.isPrivateKeyVisible - }); - }; - - private togglePassword = () => { - this.setState({ - isPasswordVisible: !this.state.isPasswordVisible - }); - }; - private handleInput = (e: React.FormEvent) => { const { name, value } = e.currentTarget; let { keystoreFile } = this.state; diff --git a/common/components/TogglablePassword.scss b/common/components/TogglablePassword.scss new file mode 100644 index 00000000..ae4fb8d6 --- /dev/null +++ b/common/components/TogglablePassword.scss @@ -0,0 +1,13 @@ +@import 'common/sass/variables'; + +.TogglablePassword { + &-toggle { + cursor: pointer; + color: $gray; + transition: $transition; + + &:hover { + color: $gray-darker; + } + } +} diff --git a/common/components/TogglablePassword.tsx b/common/components/TogglablePassword.tsx new file mode 100644 index 00000000..8c66fde7 --- /dev/null +++ b/common/components/TogglablePassword.tsx @@ -0,0 +1,108 @@ +// Either self contained, or controlled component for having a password field +// with a toggle to turn it into a visible text field. +// Pass `isVisible` and `handleToggleVisibility` to control the visibility +// yourself, otherwise all visibiility changes are managed in internal state. +import React from 'react'; +import './TogglablePassword.scss'; + +interface Props { + // Shared props + value: string; + placeholder?: string; + name?: string; + disabled?: boolean; + ariaLabel?: string; + toggleAriaLabel?: string; + isValid?: boolean; + isVisible?: boolean; + + // Textarea-only props + isTextareaWhenVisible?: boolean; + rows?: number; + onEnter?(): void; + + // Shared callbacks + onChange?(ev: React.FormEvent): void; + handleToggleVisibility?(): void; +} + +interface State { + isVisible: boolean; +} + +export default class TogglablePassword extends React.PureComponent { + public state: State = { + isVisible: !!this.props.isVisible + }; + + public componentWillReceiveProps(nextProps: Props) { + if (this.props.isVisible !== nextProps.isVisible) { + this.setState({ isVisible: !!nextProps.isVisible }); + } + } + + public render() { + const { + value, + placeholder, + name, + disabled, + ariaLabel, + isTextareaWhenVisible, + isValid, + onChange, + handleToggleVisibility + } = this.props; + const { isVisible } = this.state; + const validClass = + isValid === null || isValid === undefined ? '' : isValid ? 'is-valid' : 'is-invalid'; + + return ( +
+ {isTextareaWhenVisible && isVisible ? ( +