diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2017-11-23 12:39:11 +0100 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2017-11-23 12:39:11 +0100 |
| commit | 3b5ae5b7c7943fd83ee8c4b7dac3e13113f70e92 (patch) | |
| tree | fd6a05544f0a9514c917bb6b527b2f19fb6b0bcc | |
| parent | 3ca41569b1d61444d4a2bf0a0dc330fbc86435ca (diff) | |
| parent | 58e7c1c3028707d8b9b5a130a4ec84f6a1a7fd7e (diff) | |
| download | mullvadvpn-3b5ae5b7c7943fd83ee8c4b7dac3e13113f70e92.tar.xz mullvadvpn-3b5ae5b7c7943fd83ee8c4b7dac3e13113f70e92.zip | |
Merge branch 'previously-added-accounts'
| -rw-r--r-- | app/assets/images/icon-close-sml.svg | 1 | ||||
| -rw-r--r-- | app/components/AccountInput.js | 2 | ||||
| -rw-r--r-- | app/components/Login.css | 139 | ||||
| -rw-r--r-- | app/components/Login.js | 359 | ||||
| -rw-r--r-- | app/containers/LoginPage.js | 1 | ||||
| -rw-r--r-- | app/lib/backend.js | 30 | ||||
| -rw-r--r-- | app/lib/ipc-facade.js | 19 | ||||
| -rw-r--r-- | app/redux/account/actions.js | 28 | ||||
| -rw-r--r-- | app/redux/account/reducers.js | 9 | ||||
| -rw-r--r-- | package.json | 2 | ||||
| -rw-r--r-- | test/components/Account.spec.js | 2 | ||||
| -rw-r--r-- | test/components/Login.spec.js | 59 | ||||
| -rw-r--r-- | test/components/Settings.spec.js | 3 | ||||
| -rw-r--r-- | test/components/Support.spec.js | 1 | ||||
| -rw-r--r-- | test/mocks/ipc.js | 8 | ||||
| -rw-r--r-- | test/mocks/redux.js | 21 | ||||
| -rw-r--r-- | yarn.lock | 6 |
17 files changed, 458 insertions, 232 deletions
diff --git a/app/assets/images/icon-close-sml.svg b/app/assets/images/icon-close-sml.svg new file mode 100644 index 0000000000..d06479241b --- /dev/null +++ b/app/assets/images/icon-close-sml.svg @@ -0,0 +1 @@ +<svg width="16" height="16" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>icon close sml</title><desc>Created with Sketch.</desc><g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" fill-opacity=".2"><g id="Views/Login-Inputted-filltered-prev-IDs" transform="translate(-268.000000, -403.000000)" fill="#294D73"><g id="Input" transform="translate(24.000000, 338.000000)"><g id="suggestions" transform="translate(0.000000, 49.000000)"><g id="1"><path d="M253,24 L255.290281,21.7097195 C255.681035,21.3189651 255.684193,20.6841926 255.294624,20.2946243 L255.705376,20.7053757 C255.316406,20.3164062 254.682248,20.3177522 254.290281,20.7097195 L252,23 L249.709719,20.7097195 C249.317752,20.3177522 248.683594,20.3164062 248.294624,20.7053757 L248.705376,20.2946243 C248.315807,20.6841926 248.318965,21.3189651 248.709719,21.7097195 L251,24 L248.709719,26.2902805 C248.318965,26.6810349 248.315807,27.3158074 248.705376,27.7053757 L248.294624,27.2946243 C248.683594,27.6835938 249.317752,27.6822478 249.709719,27.2902805 L252,25 L254.290281,27.2902805 C254.682248,27.6822478 255.316406,27.6835938 255.705376,27.2946243 L255.294624,27.7053757 C255.684193,27.3158074 255.681035,26.6810349 255.290281,26.2902805 L253,24 Z M252,32 C247.58208,32 244,28.41792 244,24 C244,19.58208 247.58208,16 252,16 C256.41792,16 260,19.58208 260,24 C260,28.41792 256.41792,32 252,32 Z" id="icon-close-sml"/></g></g></g></g></g></svg>
\ No newline at end of file diff --git a/app/components/AccountInput.js b/app/components/AccountInput.js index 1759423437..4ff99e13fd 100644 --- a/app/components/AccountInput.js +++ b/app/components/AccountInput.js @@ -74,7 +74,7 @@ export default class AccountInput extends Component { this.state.selectionRange[1] !== nextState.selectionRange[1]); } - render(): React.Element<*> { + render() { const displayString = formatAccount(this.state.value || ''); const { value, onChange, onEnter, ...otherProps } = this.props; // eslint-disable-line no-unused-vars return ( diff --git a/app/components/Login.css b/app/components/Login.css index 8793337f0c..9311212ec9 100644 --- a/app/components/Login.css +++ b/app/components/Login.css @@ -8,15 +8,20 @@ background-color: #192E45; padding: 18px 24px 24px; flex: 0 0 auto; + transition: transform 0.25s ease-in-out; } .login-footer--invisible { - visibility: hidden; + transform: translateY(100%); } .login-form__status-icon { + flex: 0 0 auto; /* never collapse or grow */ text-align: center; margin-bottom: 44px; + + /* use fixed size to make space and avoid jitter when changing <img> visibility */ + height: 60px; } .login-footer__prompt { @@ -32,7 +37,7 @@ display: flex; flex-direction: column; padding: 0 24px; - margin: auto 0 70px; + margin: 83px 0 auto; } .login-form__title { @@ -53,46 +58,57 @@ margin-bottom: 8px; } -.login-form__input-wrap { - border: 2px solid transparent; - background-color: #FFFFFF; - background-clip: content-box; - border-radius: 8px; +.login-form__account-input-container { + /* + Provides an anchor for input-container + to be properly positioned when it gains absolute + position to overlap the footer view. + */ + position: relative; + + /* push border to the outer side of the layout grid */ margin-left: -2px; margin-right: -2px; - display: flex; - flex-direction: row; - overflow: hidden; - transition-duration: 0.3s; - transition-property: background-color, border-color; - transition-timing-function: ease-in-out; +} - /* this fixes border-radius clipping bug when button animates opacity */ - -webkit-backface-visibility: hidden; - -webkit-transform: translate3d(0, 0, 0); +.login-form__account-input-group { + border: 2px solid transparent; + border-radius: 8px; + transition: border 0.25s ease-in-out; + overflow: hidden; } -.login-form__input-wrap--active { +.login-form__account-input-group--active { + position: absolute; border-color: rgba(25,46,69,0.4); } -.login-form__input-wrap--inactive { +.login-form__account-input-group--inactive { opacity: 0.6; } -.login-form__input-field::-webkit-input-placeholder { - color: rgba(41,77,115,0.4); +.login-form__account-input-group--error { + border-color: rgba(208,2,27,0.4); } -.login-form__fields--invisible { - visibility: hidden; +.login-form__account-input-group--error .login-form__account-input-textfield { + color: #D0021B; +} + +.login-form__account-input-backdrop { + background-color: #FFFFFF; + display: flex; + flex-direction: row; + transition: background-color 0.25s ease-in-out; } -.login-form__input-field { +.login-form__account-input-textfield::-webkit-input-placeholder { + color: rgba(41,77,115,0.4); +} + +.login-form__account-input-textfield { width: 100%; - border-radius: 8px; border: 0; - overflow: hidden; padding: 10px 12px 12px 12px; font-family: DINPro; font-size: 20px; @@ -104,19 +120,11 @@ transition: 0.3s color ease-in-out; } -.login-form__input-field--inactive { +.login-form__account-input-textfield--inactive { background-color: rgba(255,255,255,0.6); } -.login-form__input-wrap--error { - border-color: rgba(208,2,27,0.4); -} - -.login-form__input-wrap--error .login-form__input-field { - color: #D0021B; -} - -.login-form__submit { +.login-form__account-input-button { flex: 0 0 auto; border: 0; width: 48px; @@ -126,29 +134,78 @@ transition-timing-function: ease-in-out; } -.login-form__submit-icon { +.login-form__account-input-button-icon { display: inline-block; vertical-align: middle; } -.login-form__submit-icon path { +.login-form__account-input-button-icon path { fill: rgba(41,77,115,0.2); transition: fill 0.3s ease-in-out; } -.login-form__submit--active { +.login-form__account-input-button--active { background-color: #44ad4d; } -.login-form__submit--active .login-form__submit-icon path { +.login-form__account-input-button--active .login-form__account-input-button-icon path { fill: #fff; } -.login-form__submit--active:hover { +.login-form__account-input-button--active:hover { background-color: rgba(68, 173, 76, 0.9); } -.login-form__submit--invisible { +.login-form__account-input-button--invisible { visibility: hidden; opacity: 0; } + +.login-form__account-dropdown-container { + overflow: hidden; + transition: height 0.25s ease-in-out; +} + +.login-form__account-dropdown__item { + display: flex; + flex-direction: row; + border-top: 1px solid rgba(25,46,69,0.4); + background-color: rgba(255,255,255,0.6); + background-clip: padding-box; + transition: background-color 0.25s ease-in-out; +} + +.login-form__account-dropdown__item:hover { + background-color: rgba(255,255,255,0.65); +} + +.login-form__account-dropdown__label { + flex: 1 1 auto; + font-family: DINPro; + font-size: 20px; + font-weight: 900; + line-height: 26px; + color: #294D73; + border: 0; + background: transparent; + padding: 10px 12px 12px 12px; + text-align: left; +} + +.login-form__account-dropdown__remove { + background: transparent; + border: 0; + padding: 10px 12px 12px 12px; + + /* center SVG within button */ + display: flex; + justify-content: center; +} + +.login-form__account-dropdown__remove path { + transition: fill-opacity 0.25s ease-in-out; +} + +.login-form__account-dropdown__remove:hover path { + fill-opacity: 1; +}
\ No newline at end of file diff --git a/app/components/Login.js b/app/components/Login.js index 784d92daa8..c133954eed 100644 --- a/app/components/Login.js +++ b/app/components/Login.js @@ -2,18 +2,22 @@ import React, { Component } from 'react'; import { Layout, Container, Header } from './Layout'; import AccountInput from './AccountInput'; +import { formatAccount } from '../lib/formatters'; import ExternalLinkSVG from '../assets/images/icon-extLink.svg'; import LoginArrowSVG from '../assets/images/icon-arrow.svg'; +import RemoveAccountSVG from '../assets/images/icon-close-sml.svg'; -import type { AccountReduxState, LoginState } from '../redux/account/reducers'; +import type { AccountReduxState } from '../redux/account/reducers'; +import type { AccountToken } from '../lib/ipc-facade'; export type LoginPropTypes = { account: AccountReduxState, - onLogin: (accountToken: string) => void, + onLogin: (accountToken: AccountToken) => void, onSettings: ?(() => void), onFirstChangeAfterFailure: () => void, onExternalLink: (type: string) => void, - onAccountTokenChange: (string) => void, + onAccountTokenChange: (accountToken: AccountToken) => void, + onRemoveAccountTokenFromHistory: (accountToken: AccountToken) => void, }; export default class Login extends Component { @@ -21,75 +25,22 @@ export default class Login extends Component { state = { notifyOnFirstChangeAfterFailure: false, isActive: false, + dropdownHeight: 0 }; - onCreateAccount = () => this.props.onExternalLink('createAccount'); - onFocus = () => this.setState({ isActive: true }); - onBlur = () => this.setState({ isActive: false }); - onLogin = () => { - const accountToken = this.props.account.accountToken; - if(accountToken && accountToken.length > 0) { - this.props.onLogin(accountToken); + constructor(props: LoginPropTypes) { + super(props); + if(props.account.status === 'failed') { + this.state.notifyOnFirstChangeAfterFailure = true; } } - onInputChange = (val: string) => { - // notify delegate on first change after login failure - if(this.state.notifyOnFirstChangeAfterFailure) { - this.setState({ notifyOnFirstChangeAfterFailure: false }); - this.props.onFirstChangeAfterFailure(); - } - this.props.onAccountTokenChange(val); - } - - formTitle(s: LoginState): string { - switch(s) { - case 'logging in': return 'Logging in...'; - case 'failed': return 'Login failed'; - case 'ok': return 'Login successful'; - default: return 'Login'; - } - } - - formSubtitle(s: LoginState, e: ?Error): string { - switch(s) { - case 'failed': return (e && e.message) || 'Unknown error'; - case 'logging in': return 'Checking account number'; - default: return 'Enter your account number'; - } - } - - inputWrapClass(s: LoginState): string { - const classes = ['login-form__input-wrap']; - - if(this.state.isActive) { - classes.push('login-form__input-wrap--active'); - } - - switch(s) { - case 'logging in': - classes.push('login-form__input-wrap--inactive'); - break; - case 'failed': - classes.push('login-form__input-wrap--error'); - break; - } - - return classes.join(' '); + componentDidMount() { + this._updateDropdownHeight(); } - submitClass(s: LoginState, accountToken: ?string): string { - const classes = ['login-form__submit']; - - if(accountToken && accountToken.length > 0) { - classes.push('login-form__submit--active'); - } - - if(s === 'logging in') { - classes.push('login-form__submit--invisible'); - } - - return classes.join(' '); + componentDidUpdate() { + this._updateDropdownHeight(); } componentWillReceiveProps(nextProps: LoginPropTypes) { @@ -101,38 +52,25 @@ export default class Login extends Component { } } - render(): React.Element<*> { - const { status } = this.props.account; - const title = this.formTitle(status); - - const shouldShowLoginForm = status !== 'ok'; - const shouldShowFooter = status === 'none' || status === 'failed'; - - const statusIcon = this._getStatusIcon(); - - const loginFormClass = shouldShowLoginForm ? '' : 'login-form__fields--invisible'; - const loginForm = this._createLoginForm(); - - const footerClass = shouldShowFooter ? '' : 'login-footer--invisible'; - const footer = this._createFooter(); - + render() { + const footerClass = this._shouldShowFooter() ? '' : 'login-footer--invisible'; return ( <Layout> <Header showSettings={ true } onSettings={ this.props.onSettings } /> <Container> <div className="login"> <div className="login-form"> - { statusIcon } + { this._getStatusIcon() } - <div className="login-form__title">{ title }</div> + <div className="login-form__title">{ this._formTitle() }</div> - <div className={ 'login-form__fields ' + loginFormClass }> - { loginForm } - </div> + {this._shouldShowLoginForm() && <div className='login-form__fields'> + { this._createLoginForm() } + </div>} </div> <div className={ 'login-footer ' + footerClass }> - { footer } + { this._createFooter() } </div> </div> </Container> @@ -140,11 +78,67 @@ export default class Login extends Component { ); } - _getStatusIcon(): React.Element<*> { - const statusIconPath = this._getStatusIconPath(); + _onCreateAccount = () => this.props.onExternalLink('createAccount'); + _onFocus = () => this.setState({ isActive: true }); + _onBlur = (e) => { + const relatedTarget = e.relatedTarget; + + // restore focus if click happened within dropdown + if(relatedTarget && this._isWithinDropdown(relatedTarget)) { + e.target.focus(); + return; + } + + this.setState({ isActive: false }); + } + + _onLogin = () => { + const accountToken = this.props.account.accountToken; + if(accountToken && accountToken.length > 0) { + this.props.onLogin(accountToken); + } + } + _onInputChange = (value: string) => { + // notify delegate on first change after login failure + if(this.state.notifyOnFirstChangeAfterFailure) { + this.setState({ notifyOnFirstChangeAfterFailure: false }); + this.props.onFirstChangeAfterFailure(); + } + this.props.onAccountTokenChange(value); + } + + _formTitle() { + switch(this.props.account.status) { + case 'logging in': + return 'Logging in...'; + case 'failed': + return 'Login failed'; + case 'ok': + return 'Login successful'; + default: + return 'Login'; + } + } + + _formSubtitle() { + const { status, error } = this.props.account; + switch(status) { + case 'failed': + return (error && error.message) || 'Unknown error'; + case 'logging in': + return 'Checking account number'; + default: + return 'Enter your account number'; + } + } + + _getStatusIcon() { + const statusIconPath = this._getStatusIconPath(); return <div className="login-form__status-icon"> - <img src={ statusIconPath } alt="" /> + { statusIconPath ? + <img src={ statusIconPath } alt="" /> : + null } </div>; } @@ -161,48 +155,137 @@ export default class Login extends Component { } } - _createLoginForm(): React.Element<*> { - const { status, error } = this.props.account; - const accountToken = this.props.account.accountToken; + _accountInputGroupClass(): string { + const classes = ['login-form__account-input-group']; + if(this.state.isActive) { + classes.push('login-form__account-input-group--active'); + } + + switch(this.props.account.status) { + case 'logging in': + classes.push('login-form__account-input-group--inactive'); + break; + case 'failed': + classes.push('login-form__account-input-group--error'); + break; + } + + return classes.join(' '); + } + + _accountInputButtonClass(): string { + const { accountToken, status } = this.props.account; + const classes = ['login-form__account-input-button']; + + if(accountToken && accountToken.length > 0) { + classes.push('login-form__account-input-button--active'); + } + + if(status === 'logging in') { + classes.push('login-form__account-input-button--invisible'); + } + + return classes.join(' '); + } + + _shouldEnableAccountInput() { + // enable account input always except when "logging in" + return this.props.account.status !== 'logging in'; + } + + _shouldShowAccountHistory() { + return this._shouldEnableAccountInput() && + this.state.isActive && + this.props.account.accountHistory.length > 0; + } + + _shouldShowLoginForm() { + return this.props.account.status !== 'ok'; + } - const inputDisabled = status === 'logging in'; + _shouldShowFooter() { + const { status } = this.props.account; + return (status === 'none' || status === 'failed') && !this._shouldShowAccountHistory(); + } - const subtitle = this.formSubtitle(status, error); + // helper function to calculate and save dropdown element's height + // this is a no-op of the height didn't change since last update + _updateDropdownHeight() { + const element = this._accountDropdownElement; + if(element && this.state.dropdownHeight !== element.clientHeight) { + this.setState({ + dropdownHeight: element.clientHeight + }); + } + } + + // returns true if DOM node is within dropdown hierarchy + _isWithinDropdown(relatedTarget) { + const dropdownElement = this._accountDropdownElement; + return dropdownElement && dropdownElement.contains(relatedTarget); + } - const inputWrapClass = this.inputWrapClass(status); - const submitClass = this.submitClass(status, accountToken); + // container element used for measuring the height of the accounts dropdown + _accountDropdownElement: ?HTMLElement; + _onAccountDropdownContainerRef = ref => this._accountDropdownElement = ref; - const autoFocusRef = input => { - if(status === 'failed' && input) { + _onSelectAccountFromHistory = (accountToken) => { + this.props.onAccountTokenChange(accountToken); + this.props.onLogin(accountToken); + } + + _createLoginForm() { + const { accountHistory, accountToken } = this.props.account; + const dropdownStyles = { + height: this._shouldShowAccountHistory() ? this.state.dropdownHeight : 0 + }; + + // auto-focus on account input when failed to log in + // do not refactor this into instance method, + // it has to be new function each time to be called on each render + const autoFocusOnFailure = (input) => { + if(this.props.account.status === 'failed' && input) { input.focus(); } }; return <div> - <div className="login-form__subtitle">{ subtitle }</div> - <div className={ inputWrapClass }> - <AccountInput className="login-form__input-field" - type="text" - placeholder="e.g 0000 0000 0000" - onFocus={ this.onFocus } - onBlur={ this.onBlur } - onChange={ this.onInputChange } - onEnter={ this.onLogin } - value={ accountToken || '' } - disabled={ inputDisabled } - autoFocus={ true } - ref={ autoFocusRef } /> - <button className={ submitClass } onClick={ this.onLogin }> - <LoginArrowSVG className="login-form__submit-icon" /> - </button> + <div className="login-form__subtitle">{ this._formSubtitle() }</div> + <div className="login-form__account-input-container"> + <div className={ this._accountInputGroupClass() }> + <div className="login-form__account-input-backdrop"> + <AccountInput className="login-form__account-input-textfield" + type="text" + placeholder="e.g 0000 0000 0000" + onFocus={ this._onFocus } + onBlur={ this._onBlur } + onChange={ this._onInputChange } + onEnter={ this._onLogin } + value={ accountToken || '' } + disabled={ !this._shouldEnableAccountInput() } + autoFocus={ true } + ref={ autoFocusOnFailure } /> + <button className={ this._accountInputButtonClass() } onClick={ this._onLogin }> + <LoginArrowSVG className="login-form__account-input-button-icon" /> + </button> + </div> + <div style={ dropdownStyles } className="login-form__account-dropdown-container"> + <div ref={ this._onAccountDropdownContainerRef }> + { <AccountDropdown + items={ accountHistory.slice().reverse() } + onSelect={ this._onSelectAccountFromHistory } + onRemove={ this.props.onRemoveAccountTokenFromHistory } /> } + </div> + </div> + </div> </div> </div>; } - _createFooter(): React.Element<*> { + _createFooter() { return <div> <div className="login-footer__prompt">{ 'Don\'t have an account number?' }</div> - <button className="button button--primary" onClick={ this.onCreateAccount }> + <button className="button button--primary" onClick={ this._onCreateAccount }> <span className="button-label">Create account</span> <ExternalLinkSVG className="button-icon button-icon--16" /> </button> @@ -210,3 +293,47 @@ export default class Login extends Component { } } +class AccountDropdown extends Component { + props: { + items: Array<AccountToken>, + onSelect: ((value: AccountToken) => void), + onRemove: ((value: AccountToken) => void) + }; + + render() { + const uniqueItems = [...new Set(this.props.items)]; + return ( + <div className="login-form__account-dropdown"> + { uniqueItems.map(token => ( + <AccountDropdownItem key={ token } + value={ token } + label={ formatAccount(token) } + onSelect={ this.props.onSelect } + onRemove={ this.props.onRemove } /> + )) } + </div> + ); + } +} + +class AccountDropdownItem extends Component { + props: { + label: string, + value: AccountToken, + onRemove: (value: AccountToken) => void, + onSelect: (value: AccountToken) => void + }; + + render() { + return ( + <div className="login-form__account-dropdown__item"> + <button className="login-form__account-dropdown__label" + onClick={ () => this.props.onSelect(this.props.value) }>{ this.props.label }</button> + <button className="login-form__account-dropdown__remove" + onClick={ () => this.props.onRemove(this.props.value) }> + <RemoveAccountSVG /> + </button> + </div> + ); + } +} diff --git a/app/containers/LoginPage.js b/app/containers/LoginPage.js index 457be7a6f2..f7df9c5595 100644 --- a/app/containers/LoginPage.js +++ b/app/containers/LoginPage.js @@ -16,6 +16,7 @@ const mapDispatchToProps = (dispatch, props) => { onFirstChangeAfterFailure: () => dispatch(accountActions.resetLoginError()), onExternalLink: (type) => shell.openExternal(links[type]), onAccountTokenChange: (token) => dispatch(accountActions.updateAccountToken(token)), + onRemoveAccountTokenFromHistory: (token) => backend.removeAccountFromHistory(token), }; }; diff --git a/app/lib/backend.js b/app/lib/backend.js index fe828ba6c8..4dd389d759 100644 --- a/app/lib/backend.js +++ b/app/lib/backend.js @@ -11,10 +11,9 @@ import { push } from 'react-router-redux'; import { defaultServer } from '../config'; import type { ReduxStore } from '../redux/store'; -import type { BackendState, RelayConstraintsUpdate } from './ipc-facade'; +import type { AccountToken, BackendState, RelayConstraintsUpdate } from './ipc-facade'; import type { ConnectionState } from '../redux/connection/reducers'; -export type EventType = 'connect' | 'connecting' | 'disconnect' | 'login' | 'logging' | 'logout' | 'updatedIp' | 'updatedLocation' | 'updatedReachability'; export type ErrorType = 'NO_CREDIT' | 'NO_INTERNET' | 'INVALID_ACCOUNT' | 'NO_ACCOUNT'; export type ServerInfo = { @@ -162,13 +161,15 @@ export class Backend { log.info('Failed getting new location,', e.message); }); }); + + this._updateAccountHistory(); } serverInfo(identifier: string): ?ServerInfo { return (servers: ServerInfoList)[identifier]; } - login(accountToken: string): Promise<void> { + login(accountToken: AccountToken): Promise<void> { log.debug('Attempting to login with account number', accountToken); this._store.dispatch(accountActions.startLogin(accountToken)); @@ -205,7 +206,7 @@ export class Backend { // TODO: This is not true. If there is a communication link failure the promise will be rejected too const err = new BackendError('INVALID_ACCOUNT'); this._store.dispatch(accountActions.loginFailed(err)); - }); + }).then(() => this._updateAccountHistory()); }); } @@ -249,7 +250,6 @@ export class Backend { .then( () => { return this._ipc.setAccount(null) .then(() => { - this._store.dispatch(accountActions.loggedOut()); // disconnect user during logout @@ -334,6 +334,26 @@ export class Backend { }); } + removeAccountFromHistory(accountToken: AccountToken): Promise<void> { + return this._ensureAuthenticated() + .then(() => this._ipc.removeAccountFromHistory(accountToken)) + .then(() => this._updateAccountHistory()) + .catch(e => { + log.error('Failed to remove account token from history', e.message); + }); + } + + _updateAccountHistory(): Promise<void> { + return this._ensureAuthenticated() + .then(() => this._ipc.getAccountHistory()) + .then((accountHistory) => { + this._store.dispatch( + accountActions.updateAccountHistory(accountHistory) + ); + }) + .catch(e => log.info('Failed to fetch account history,', e.message)); + } + _apiToReduxConstraints(constraint: *): * { if (typeof(constraint) === 'object') { return constraint.only; diff --git a/app/lib/ipc-facade.js b/app/lib/ipc-facade.js index 6cd224a441..143a2c0630 100644 --- a/app/lib/ipc-facade.js +++ b/app/lib/ipc-facade.js @@ -73,6 +73,8 @@ export interface IpcFacade { registerStateListener((BackendState) => void): void, setCloseConnectionHandler(() => void): void, authenticate(sharedSecret: string): Promise<void>, + getAccountHistory(): Promise<Array<AccountToken>>, + removeAccountFromHistory(accountToken: AccountToken): Promise<void>, } export class RealIpc implements IpcFacade { @@ -218,4 +220,21 @@ export class RealIpc implements IpcFacade { return this._ipc.send('auth', sharedSecret) .then(this._ignoreResponse); } + + getAccountHistory(): Promise<Array<AccountToken>> { + return this._ipc.send('get_account_history') + .then(raw => { + if(Array.isArray(raw) && raw.every(i => typeof i === 'string')) { + const checked: any = raw; + return (checked: Array<AccountToken>); + } else { + throw new InvalidReply(raw, 'Expected an array of strings'); + } + }); + } + + removeAccountFromHistory(accountToken: AccountToken): Promise<void> { + return this._ipc.send('remove_account_from_history', accountToken) + .then(this._ignoreResponse); + } } diff --git a/app/redux/account/actions.js b/app/redux/account/actions.js index d528f4f3d0..8932e6ae37 100644 --- a/app/redux/account/actions.js +++ b/app/redux/account/actions.js @@ -1,15 +1,18 @@ // @flow +import type { AccountToken } from '../../lib/ipc-facade'; import type { Backend, BackendError } from '../../lib/backend'; type StartLoginAction = { type: 'START_LOGIN', - accountToken?: string, + accountToken?: AccountToken, }; + type LoginSuccessfulAction = { type: 'LOGIN_SUCCESSFUL', expiry: string, }; + type LoginFailedAction = { type: 'LOGIN_FAILED', error: BackendError, @@ -25,16 +28,23 @@ type ResetLoginErrorAction = { type UpdateAccountTokenAction = { type: 'UPDATE_ACCOUNT_TOKEN', - token: string, + token: AccountToken, +}; + +type UpdateAccountHistoryAction = { + type: 'UPDATE_ACCOUNT_HISTORY', + accountHistory: Array<AccountToken>, }; export type AccountAction = StartLoginAction | LoginSuccessfulAction | LoginFailedAction | LoggedOutAction - | ResetLoginErrorAction; + | ResetLoginErrorAction + | UpdateAccountTokenAction + | UpdateAccountHistoryAction; -function startLogin(accountToken?: string): StartLoginAction { +function startLogin(accountToken?: AccountToken): StartLoginAction { return { type: 'START_LOGIN', accountToken: accountToken, @@ -71,13 +81,20 @@ function resetLoginError(): ResetLoginErrorAction { }; } -function updateAccountToken(token: string): UpdateAccountTokenAction { +function updateAccountToken(token: AccountToken): UpdateAccountTokenAction { return { type: 'UPDATE_ACCOUNT_TOKEN', token: token, }; } +function updateAccountHistory(accountHistory: Array<AccountToken>): UpdateAccountHistoryAction { + return { + type: 'UPDATE_ACCOUNT_HISTORY', + accountHistory: accountHistory, + }; +} + const login = (backend: Backend, account: string) => () => backend.login(account); const logout = (backend: Backend) => () => backend.logout(); @@ -91,4 +108,5 @@ export default { autoLoginFailed, resetLoginError, updateAccountToken, + updateAccountHistory, }; diff --git a/app/redux/account/reducers.js b/app/redux/account/reducers.js index 27530499c9..95909e3d74 100644 --- a/app/redux/account/reducers.js +++ b/app/redux/account/reducers.js @@ -2,10 +2,12 @@ import type { ReduxAction } from '../store'; import type { BackendError } from '../../lib/backend'; +import type { AccountToken } from '../../lib/ipc-facade'; export type LoginState = 'none' | 'logging in' | 'failed' | 'ok'; export type AccountReduxState = { - accountToken: ?string, + accountToken: ?AccountToken, + accountHistory: Array<AccountToken>, expiry: ?string, // ISO8601 status: LoginState, error: ?BackendError @@ -13,6 +15,7 @@ export type AccountReduxState = { const initialState: AccountReduxState = { accountToken: null, + accountHistory: [], expiry: null, status: 'none', error: null @@ -57,6 +60,10 @@ export default function(state: AccountReduxState = initialState, action: ReduxAc return { ...state, ...{ accountToken: action.token, }}; + case 'UPDATE_ACCOUNT_HISTORY': + return { ...state, ...{ + accountHistory: action.accountHistory, + }}; } return state; diff --git a/package.json b/package.json index 4fc525c01f..3d6a410ce6 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "flow-typed": "^2.1.5", "npm-run-all": "^4.0.1", "react-test-renderer": "^15.6.1", - "redux-mock-store": "^1.2.2", + "redux-mock-store": "^1.3.0", "rimraf": "^2.5.4", "sinon": "^3.2.1" }, diff --git a/test/components/Account.spec.js b/test/components/Account.spec.js index 4f574ae6d7..a8b8e3d501 100644 --- a/test/components/Account.spec.js +++ b/test/components/Account.spec.js @@ -11,6 +11,7 @@ import type { AccountProps } from '../../app/components/Account'; describe('components/Account', () => { const state: AccountReduxState = { accountToken: '1234', + accountHistory: [], expiry: (new Date('2038-01-01')).toISOString(), status: 'none', error: null @@ -59,6 +60,7 @@ describe('components/Account', () => { it('should display "out of time" message when account expired', () => { const expiredState: AccountReduxState = { accountToken: '1234', + accountHistory: [], expiry: (new Date('2001-01-01')).toISOString(), status: 'none', error: null diff --git a/test/components/Login.spec.js b/test/components/Login.spec.js index 9918dc6e1c..f34d9ea405 100644 --- a/test/components/Login.spec.js +++ b/test/components/Login.spec.js @@ -8,68 +8,55 @@ import sinon from 'sinon'; import Login from '../../app/components/Login'; import AccountInput from '../../app/components/AccountInput'; -import type { ShallowWrapper } from 'enzyme'; - describe('components/Login', () => { it('notifies on the first change after failure', () => { - - let callback = sinon.spy(); + let onFirstChange = sinon.spy(); const props = { - onFirstChangeAfterFailure: callback, + account: Object.assign({}, defaultAccount, { + status: 'failed' + }), + onFirstChangeAfterFailure: onFirstChange, }; const component = renderWithProps( props ); const accountInput = component.find(AccountInput); - // Change the props to a failed state - component.setProps({ account: { - status: 'failed', - }}); - + accountInput.simulate('change', 'foo'); + expect(onFirstChange.calledOnce).to.be.true; - // Write something in the input field - setInputText(accountInput, 'foo'); - expect(callback.calledOnce).to.be.true; + onFirstChange.reset(); - - // Reset the test state - callback.reset(); - - // Write some other thing in the input field - setInputText(accountInput, 'bar'); - expect(callback.calledOnce).to.be.false; + accountInput.simulate('change', 'bar'); + expect(onFirstChange.calledOnce).to.be.false; }); - it('doesn\'t show the footer when logging in', () => { + it('does not show the footer when logging in', () => { const component = renderLoggingIn(); - const footer = component.find('.login-footer'); expect(footer.hasClass('login-footer--invisible')).to.be.true; }); it('shows the footer and account input when not logged in', () => { const component = renderNotLoggedIn(); - const footer = component.find('.login-footer'); expect(footer.hasClass('login-footer--invisible')).to.be.false; expect(component.find(AccountInput).exists()).to.be.true; }); - it('doesn\'t show the footer nor account input when logged in', () => { + it('does not show the footer nor account input when logged in', () => { const component = renderLoggedIn(); - const footer = component.find('.login-footer'); expect(footer.hasClass('login-footer--invisible')).to.be.true; - expect(component.find('.login-form__fields').hasClass('login-form__fields--invisible')).to.be.true; + expect(component.find('.login-form__fields')).to.have.length(0); }); it('logs in with the entered account number when clicking the login icon', (done) => { const component = renderNotLoggedIn(); component.setProps({ - account: { - accountToken: '12345', - }, + account: Object.assign({}, defaultAccount, { + accountToken: '12345' + }), onLogin: (an) => { try { expect(an).to.equal('12345'); @@ -80,15 +67,16 @@ describe('components/Login', () => { }, }); - component.find('.login-form__submit').simulate('click'); + component.find('.login-form__account-input-button').simulate('click'); }); }); const defaultAccount = { accountToken: null, + accountHistory: [], expiry: null, status: 'none', - error: null, + error: null }; const defaultProps = { @@ -98,7 +86,8 @@ const defaultProps = { onChange: () => {}, onFirstChangeAfterFailure: () => {}, onExternalLink: () => {}, - onAccountTokenChange: () => {}, + onAccountTokenChange: (_accountToken) => {}, + onRemoveAccountTokenFromHistory: (_accountToken) => {}, }; function renderLoggedIn() { @@ -125,11 +114,7 @@ function renderNotLoggedIn() { }); } -function renderWithProps(customProps): ShallowWrapper { +function renderWithProps(customProps) { const props = Object.assign({}, defaultProps, customProps); return shallow( <Login { ...props } /> ); } - -function setInputText(input: ShallowWrapper, text: string) { - input.simulate('change', text); -} diff --git a/test/components/Settings.spec.js b/test/components/Settings.spec.js index 85fd72ece5..5d29aa0dd6 100644 --- a/test/components/Settings.spec.js +++ b/test/components/Settings.spec.js @@ -12,6 +12,7 @@ import type { SettingsProps } from '../../app/components/Settings'; describe('components/Settings', () => { const loggedOutAccountState: AccountReduxState = { accountToken: null, + accountHistory: [], expiry: null, status: 'none', error: null @@ -19,6 +20,7 @@ describe('components/Settings', () => { const loggedInAccountState: AccountReduxState = { accountToken: '1234', + accountHistory: [], expiry: (new Date('2038-01-01')).toISOString(), status: 'ok', error: null @@ -26,6 +28,7 @@ describe('components/Settings', () => { const unpaidAccountState: AccountReduxState = { accountToken: '1234', + accountHistory: [], expiry: (new Date('2001-01-01')).toISOString(), status: 'ok', error: null diff --git a/test/components/Support.spec.js b/test/components/Support.spec.js index b5b9b3d24b..3251b08464 100644 --- a/test/components/Support.spec.js +++ b/test/components/Support.spec.js @@ -14,6 +14,7 @@ describe('components/Support', () => { const defaultProps: SupportProps = { account: { accountToken: null, + accountHistory: [], error: null, expiry: null, status: 'none', diff --git a/test/mocks/ipc.js b/test/mocks/ipc.js index 53ac487dd5..2bda0b3fd6 100644 --- a/test/mocks/ipc.js +++ b/test/mocks/ipc.js @@ -67,12 +67,16 @@ export function newMockIpc() { } }, - authenticate: (_secret: string) => Promise.resolve(), - setCloseConnectionHandler: (listener: () => void) => { connectionCloseListeners.push(listener); }, + authenticate: (_secret: string) => Promise.resolve(), + + getAccountHistory: () => Promise.resolve([]), + + removeAccountFromHistory: (_accountToken) => Promise.resolve(), + killWebSocket: () => { for(const l of connectionCloseListeners) { l(); diff --git a/test/mocks/redux.js b/test/mocks/redux.js index 5f0562c035..a652009645 100644 --- a/test/mocks/redux.js +++ b/test/mocks/redux.js @@ -1,23 +1,4 @@ import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; -const middlewares = [ thunk ]; -export const mockStore = configureMockStore(middlewares); - -export const filterMinorActions = (actions) => { - return actions.filter((action) => { - if(action.type === 'CONNECTION_CHANGE' && action.payload.clientIp) { - return false; - } - - if(action.type === 'CONNECTION_CHANGE' && action.payload.isOnline) { - return false; - } - - if(action.type === 'USER_LOGIN_CHANGE' && action.payload.city) { - return false; - } - - return true; - }); -}; +export const mockStore = configureMockStore([ thunk ]); @@ -4963,9 +4963,9 @@ redent@^1.0.0: indent-string "^2.1.0" strip-indent "^1.0.1" -redux-mock-store@^1.2.2: - version "1.2.3" - resolved "https://registry.yarnpkg.com/redux-mock-store/-/redux-mock-store-1.2.3.tgz#1b3ad299da91cb41ba30d68e3b6f024475fb9e1b" +redux-mock-store@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/redux-mock-store/-/redux-mock-store-1.3.0.tgz#6edfef0d2332f20576381069d6d889a6d0a4451c" redux-thunk@^2.2.0: version "2.2.0" |
