diff --git a/internal_packages/notifications/lib/headers/account-error-header.jsx b/internal_packages/notifications/lib/headers/account-error-header.jsx index beb622af3..1c713b945 100644 --- a/internal_packages/notifications/lib/headers/account-error-header.jsx +++ b/internal_packages/notifications/lib/headers/account-error-header.jsx @@ -11,9 +11,18 @@ export default class AccountErrorHeader extends React.Component { } componentDidMount() { + this.mounted = true; this.unsubscribe = AccountStore.listen(() => this._onAccountsChanged()); } + componentWillUnmount() { + this.mounted = false; + if (this.unsubscribe) { + this.unsubscribe(); + this.unsubscribe = null; + } + } + getStateFromStores() { return {accounts: AccountStore.accounts()} } @@ -22,9 +31,9 @@ export default class AccountErrorHeader extends React.Component { this.setState(this.getStateFromStores()) } - _reconnect(account) { + _reconnect(existingAccount) { const ipc = require('electron').ipcRenderer; - ipc.send('command', 'application:add-account', account.provider); + ipc.send('command', 'application:add-account', {existingAccount}); } _openPreferences() { @@ -37,6 +46,18 @@ export default class AccountErrorHeader extends React.Component { shell.openExternal("https://support.nylas.com/hc/en-us/requests/new"); } + _onCheckAgain = (event) => { + const errorAccounts = this.state.accounts.filter(a => a.hasSyncStateError()); + this.setState({refreshing: true}); + + event.stopPropagation(); + + AccountStore.refreshHealthOfAccounts(errorAccounts.map(a => a.id)).finally(() => { + if (!this.mounted) { return; } + this.setState({refreshing: false}); + }); + } + renderErrorHeader(message, buttonName, actionCallback) { return (
@@ -52,11 +73,15 @@ export default class AccountErrorHeader extends React.Component {
{message}
+ + {this.state.refreshing ? "Checking..." : "Check Again"} + {buttonName}
- ) + + ) } render() { @@ -93,6 +118,6 @@ export default class AccountErrorHeader extends React.Component { "Open preferences", () => this._openPreferences()); } - return false; + return ; } } diff --git a/internal_packages/notifications/spec/account-error-header-spec.jsx b/internal_packages/notifications/spec/account-error-header-spec.jsx new file mode 100644 index 000000000..1735c59e2 --- /dev/null +++ b/internal_packages/notifications/spec/account-error-header-spec.jsx @@ -0,0 +1,81 @@ +import {mount} from 'enzyme'; +import AccountErrorHeader from '../lib/headers/account-error-header'; +import {AccountStore, Account, Actions, React} from 'nylas-exports' +import {ipcRenderer} from 'electron'; + +describe("AccountErrorHeader", function AccountErrorHeaderTests() { + describe("when one account is in the `invalid` state", () => { + beforeEach(() => { + spyOn(AccountStore, 'accounts').andReturn([ + new Account({id: 'A', syncState: 'invalid', emailAddress: '123@gmail.com'}), + new Account({id: 'B', syncState: 'running', emailAddress: 'other@gmail.com'}), + ]) + }); + + it("renders an error bar that mentions the account email", () => { + const header = mount(); + expect(header.find('.notifications-sticky-item')).toBeDefined(); + expect(header.find('.message').text().indexOf('123@gmail.com') > 0).toBe(true); + }); + + it("allows the user to refresh the account", () => { + const header = mount(); + spyOn(AccountStore, 'refreshHealthOfAccounts').andReturn(Promise.resolve()); + header.find('.action.refresh').simulate('click'); + expect(AccountStore.refreshHealthOfAccounts).toHaveBeenCalledWith(['A']); + }); + + it("allows the user to reconnect the account", () => { + const header = mount(); + spyOn(ipcRenderer, 'send'); + header.find('.action.default').simulate('click'); + expect(ipcRenderer.send).toHaveBeenCalledWith('command', 'application:add-account', { + existingAccount: AccountStore.accounts()[0], + }); + }); + }); + + describe("when more than one account is in the `invalid` state", () => { + beforeEach(() => { + spyOn(AccountStore, 'accounts').andReturn([ + new Account({id: 'A', syncState: 'invalid', emailAddress: '123@gmail.com'}), + new Account({id: 'B', syncState: 'invalid', emailAddress: 'other@gmail.com'}), + ]) + }); + + it("renders an error bar", () => { + const header = mount(); + expect(header.find('.notifications-sticky-item')).toBeDefined(); + }); + + it("allows the user to refresh the accounts", () => { + const header = mount(); + spyOn(AccountStore, 'refreshHealthOfAccounts').andReturn(Promise.resolve()); + header.find('.action.refresh').simulate('click'); + expect(AccountStore.refreshHealthOfAccounts).toHaveBeenCalledWith(['A', 'B']); + }); + + it("allows the user to open preferences", () => { + spyOn(Actions, 'switchPreferencesTab') + spyOn(Actions, 'openPreferences') + const header = mount(); + header.find('.action.default').simulate('click'); + expect(Actions.openPreferences).toHaveBeenCalled(); + expect(Actions.switchPreferencesTab).toHaveBeenCalledWith('Accounts'); + }); + }); + + describe("when all accounts are fine", () => { + beforeEach(() => { + spyOn(AccountStore, 'accounts').andReturn([ + new Account({id: 'A', syncState: 'running', emailAddress: '123@gmail.com'}), + new Account({id: 'B', syncState: 'running', emailAddress: 'other@gmail.com'}), + ]) + }); + + it("renders nothing", () => { + const header = mount(); + expect(header.html()).toEqual(''); + }); + }); +}); diff --git a/internal_packages/onboarding/lib/account-choose-page.cjsx b/internal_packages/onboarding/lib/account-choose-page.cjsx index ad072f038..516c3b32e 100644 --- a/internal_packages/onboarding/lib/account-choose-page.cjsx +++ b/internal_packages/onboarding/lib/account-choose-page.cjsx @@ -10,17 +10,16 @@ url = require 'url' class AccountChoosePage extends React.Component @displayName: "AccountChoosePage" - constructor: (@props) -> - @state = - email: "" - provider: "" - - componentWillUnmount: -> - @_usub?() - componentDidMount: -> - if @props.pageData.provider - providerData = _.findWhere(Providers, name: @props.pageData.provider) + {existingAccount} = @props.pageData + if existingAccount and not existingAccount.routed + # Hack to prevent coming back to this page from drilling you back in. + # This should all get re-written soon. + existingAccount.routed = true + + providerName = existingAccount.provider + providerName = 'exchange' if providerName is 'eas' + providerData = _.findWhere(Providers, {name: providerName}) if providerData @_onChooseProvider(providerData) @@ -46,16 +45,6 @@ class AccountChoosePage extends React.Component {provider.displayName} - _renderError: -> - if @state.error -
- {@state.error} -
- else
- - _onEmailChange: (event) => - @setState email: event.target.value - _onChooseProvider: (provider) => Actions.recordUserEvent('Auth Flow Started', { provider: provider.name @@ -67,7 +56,7 @@ class AccountChoosePage extends React.Component _.delay => @_onBounceToGmail(provider) , 600 - OnboardingActions.moveToPage("account-settings", {provider}) + OnboardingActions.moveToPage("account-settings", Object.assign({provider}, @props.pageData)) _base64url: (buf) -> # Python-style urlsafe_b64encode diff --git a/internal_packages/onboarding/lib/account-settings-page.cjsx b/internal_packages/onboarding/lib/account-settings-page.cjsx index 38a55de00..003781527 100644 --- a/internal_packages/onboarding/lib/account-settings-page.cjsx +++ b/internal_packages/onboarding/lib/account-settings-page.cjsx @@ -26,6 +26,10 @@ class AccountSettingsPage extends React.Component if field.default? @state.settings[field.name] = field.default + if @props.pageData.existingAccount + @state.fields.name = @props.pageData.existingAccount.name + @state.fields.email = @props.pageData.existingAccount.emailAddress + # Special case for gmail. Rather than showing a form, we poll in the # background for completion of the gmail auth on the server. if @state.provider.name is 'gmail' @@ -56,6 +60,10 @@ class AccountSettingsPage extends React.Component ) poll(pollAttemptId,5000) + componentWillUnmount: => + clearTimeout(@_resizeTimer) if @_resizeTimer + @_resizeTimer = null + render: ->
@@ -67,7 +75,7 @@ class AccountSettingsPage extends React.Component {@_renderTitle()} -
+
@@ -447,11 +455,12 @@ class AccountSettingsPage extends React.Component callback() _resize: => - setTimeout( => + clearTimeout(@_resizeTimer) if @_resizeTimer + @_resizeTimer = setTimeout( => @props.onResize?() , 10) - _fireMoveToPrevPage: => + _onMoveToPrevPage: => if @state.pageNumber > 0 @setState(pageNumber: @state.pageNumber - 1) @_resize() diff --git a/internal_packages/onboarding/lib/account-types.coffee b/internal_packages/onboarding/lib/account-types.coffee index 7b8b955a8..3a39cfe01 100644 --- a/internal_packages/onboarding/lib/account-types.coffee +++ b/internal_packages/onboarding/lib/account-types.coffee @@ -119,13 +119,13 @@ Providers = [ } ] settings: [{ - name: 'password' - type: 'password' - placeholder: 'Password' - label: 'Password' - required: true - page: 0 - }] + name: 'password' + type: 'password' + placeholder: 'Password' + label: 'Password' + required: true + page: 0 + }] }, { name: 'yahoo' displayName: 'Yahoo' @@ -158,7 +158,7 @@ Providers = [ required: true }] }, { - name: 'imap' + name: 'custom' displayName: 'IMAP / SMTP Setup' icon: 'ic-settings-account-imap.png' header_icon: 'setup-icon-provider-imap.png' @@ -260,37 +260,6 @@ Providers = [ required: true } ] -# }, { -# name: 'default' -# displayName: '' -# icon: '' -# color: '' -# fields: [ -# { -# name: 'name' -# type: 'text' -# placeholder: 'Ashton Letterman' -# label: 'Name' -# }, { -# name: 'email' -# type: 'text' -# placeholder: 'you@example.com' -# label: 'Email' -# } -# ] -# settings: [ -# { -# name: 'username' -# type: 'text' -# placeholder: 'Username' -# label: 'Username' -# }, { -# name: 'password' -# type: 'password' -# placeholder: 'Password' -# label: 'Password' -# } -# ] } ] diff --git a/internal_packages/preferences/lib/tabs/preferences-account-details.jsx b/internal_packages/preferences/lib/tabs/preferences-account-details.jsx index dd84b8d96..33f2bdd10 100644 --- a/internal_packages/preferences/lib/tabs/preferences-account-details.jsx +++ b/internal_packages/preferences/lib/tabs/preferences-account-details.jsx @@ -1,5 +1,4 @@ /* eslint global-require: 0 */ -import _ from 'underscore'; import React, {Component, PropTypes} from 'react'; import {EditableList, NewsletterSignup} from 'nylas-component-kit'; import {RegExpUtils, Account} from 'nylas-exports'; @@ -57,8 +56,8 @@ class PreferencesAccountDetails extends Component { }; _setState = (updates, callback = () => {}) => { - const updated = _.extend({}, this.state.account, updates); - this.setState({account: updated}, callback); + const account = Object.assign(this.state.account.clone(), updates); + this.setState({account}, callback); }; _setStateAndSave = (updates) => { @@ -106,12 +105,12 @@ class PreferencesAccountDetails extends Component { this._setStateAndSave({defaultAlias}); }; - _reconnect() { - const {account} = this.state; + _onReconnect = () => { const ipc = require('electron').ipcRenderer; - ipc.send('command', 'application:add-account', account.provider); + ipc.send('command', 'application:add-account', {existingAccount: this.state.account}); } - _contactSupport() { + + _onContactSupport = () => { const {shell} = require("electron"); shell.openExternal("https://support.nylas.com/hc/en-us/requests/new"); } @@ -152,17 +151,17 @@ class PreferencesAccountDetails extends Component { `Nylas N1 can no longer authenticate with ${account.emailAddress}. The password or authentication may have changed.`, "Reconnect", - () => this._reconnect()); + this._onReconnect); case Account.SYNC_STATE_STOPPED: return this._renderErrorDetail( `The cloud sync for ${account.emailAddress} has been disabled. Please contact Nylas support.`, "Contact support", - () => this._contactSupport()); + this._onContactSupport); default: return this._renderErrorDetail( `Nylas encountered an error while syncing mail for ${account.emailAddress}. Contact Nylas support for details.`, "Contact support", - () => this._contactSupport()); + this._onContactSupport); } } return null; @@ -176,7 +175,7 @@ class PreferencesAccountDetails extends Component { return (
- {this._renderSyncErrorDetails(account)} + {this._renderSyncErrorDetails()}

Account Label

+

Account Settings

+ +
+ {account.provider === 'gmail' ? 'Re-authenticate with Gmail...' : 'Update Connection Settings...'} +
+

Aliases

@@ -207,7 +212,6 @@ class PreferencesAccountDetails extends Component {
-
); } diff --git a/internal_packages/preferences/lib/tabs/preferences-account-list.jsx b/internal_packages/preferences/lib/tabs/preferences-account-list.jsx index 42bc1f5ef..8d99eec24 100644 --- a/internal_packages/preferences/lib/tabs/preferences-account-list.jsx +++ b/internal_packages/preferences/lib/tabs/preferences-account-list.jsx @@ -62,17 +62,16 @@ class PreferencesAccountList extends Component { return
; } return ( -
- -
+ ); } diff --git a/internal_packages/preferences/stylesheets/preferences-accounts.less b/internal_packages/preferences/stylesheets/preferences-accounts.less index 9693ed145..f86c50948 100644 --- a/internal_packages/preferences/stylesheets/preferences-accounts.less +++ b/internal_packages/preferences/stylesheets/preferences-accounts.less @@ -11,10 +11,13 @@ justify-content: center; .account-list { + display: flex; + flex-direction: column; + height: auto; width: 400px; .items-wrapper { - height: 440px; + flex: 1; } .account { @@ -49,7 +52,7 @@ .account-details { width: 400px; - padding-top: 20px; + padding: 20px; padding-left: @spacing-standard * 2.25; padding-right: @spacing-standard * 2.25; background-color: @gray-lighter; diff --git a/spec/nylas-api-spec.coffee b/spec/nylas-api-spec.coffee index e5ba263fa..28e49c090 100644 --- a/spec/nylas-api-spec.coffee +++ b/spec/nylas-api-spec.coffee @@ -152,18 +152,17 @@ describe "NylasAPI", -> expect(DatabaseTransaction.prototype.unpersistModel).not.toHaveBeenCalled() describe "handleAuthenticationFailure", -> - it "should post a notification", -> - spyOn(Actions, 'postNotification') - NylasAPI._handleAuthenticationFailure('/threads/1234', 'token') - expect(Actions.postNotification).toHaveBeenCalled() - expect(Actions.postNotification.mostRecentCall.args[0].message.trim()).toEqual("A mailbox action for your mail provider could not be completed. You may not be able to send or receive mail.") - - it "should include the email address if possible", -> + it "should put the account in an `invalid` state", -> + spyOn(Actions, 'updateAccount') spyOn(AccountStore, 'tokenForAccountId').andReturn('token') - spyOn(Actions, 'postNotification') NylasAPI._handleAuthenticationFailure('/threads/1234', 'token') - expect(Actions.postNotification).toHaveBeenCalled() - expect(Actions.postNotification.mostRecentCall.args[0].message.trim()).toEqual("A mailbox action for #{AccountStore.accounts()[0].emailAddress} could not be completed. You may not be able to send or receive mail.") + expect(Actions.updateAccount).toHaveBeenCalled() + expect(Actions.updateAccount.mostRecentCall.args).toEqual([AccountStore.accounts()[0].id, {syncState: 'invalid'}]) + + it "should not throw an exception if the account cannot be found", -> + spyOn(Actions, 'updateAccount') + NylasAPI._handleAuthenticationFailure('/threads/1234', 'token') + expect(Actions.updateAccount).not.toHaveBeenCalled() describe "handleModelResponse", -> beforeEach -> diff --git a/spec/stores/account-store-spec.coffee b/spec/stores/account-store-spec.coffee index b120078ba..18b0b60f9 100644 --- a/spec/stores/account-store-spec.coffee +++ b/spec/stores/account-store-spec.coffee @@ -1,5 +1,6 @@ _ = require 'underscore' keytar = require 'keytar' +NylasAPI = require '../../src/flux/nylas-api' AccountStore = require '../../src/flux/stores/account-store' Account = require('../../src/flux/models/account').default Actions = require '../../src/flux/actions' @@ -38,6 +39,7 @@ describe "AccountStore", -> }] spyOn(NylasEnv.config, 'get').andCallFake (key) => + return 'production' if key is 'env' return @configAccounts if key is 'nylas.accounts' return @configVersion if key is 'nylas.accountsVersion' return @configTokens if key is 'nylas.accountTokens' @@ -77,6 +79,21 @@ describe "AccountStore", -> expect(@instance.tokenForAccountId('A')).toEqual('A-TOKEN') expect(@instance.tokenForAccountId('B')).toEqual('B-TOKEN') + describe "in the work window and running on production", -> + it "should refresh the accounts", -> + spyOn(NylasEnv, 'isWorkWindow').andReturn(true) + @instance = new @constructor + spyOn(@instance, 'refreshHealthOfAccounts') + advanceClock(10000) + expect(@instance.refreshHealthOfAccounts).toHaveBeenCalledWith(['A', 'B']) + + describe "in the main window", -> + it "should not refresh the accounts", -> + @instance = new @constructor + spyOn(@instance, 'refreshHealthOfAccounts') + advanceClock(10000) + expect(@instance.refreshHealthOfAccounts).not.toHaveBeenCalled() + describe "accountForEmail", -> beforeEach -> @instance = new @constructor @@ -164,3 +181,45 @@ describe "AccountStore", -> expect(@instance._accounts.length).toBe 2 expect(@instance.accountForId('B')).toBe(undefined) expect(@instance.accountForId('NEVER SEEN BEFORE')).not.toBe(undefined) + + describe "refreshHealthOfAccounts", -> + beforeEach -> + @spyOnConfig() + spyOn(NylasAPI, 'makeRequest').andCallFake (options) => + if options.accountId is 'return-api-error' + Promise.reject(new Error("API ERROR")) + else + Promise.resolve({ + sync_state: 'running', + id: options.accountId, + account_id: options.accountId + }) + @instance = new @constructor + spyOn(@instance, '_save') + + it "should GET /account for each of the provided account IDs", -> + @instance.refreshHealthOfAccounts(['A', 'B']) + expect(NylasAPI.makeRequest.callCount).toBe(2) + expect(NylasAPI.makeRequest.calls[0].args).toEqual([{path: '/account', accountId: 'A'}]) + expect(NylasAPI.makeRequest.calls[1].args).toEqual([{path: '/account', accountId: 'B'}]) + + it "should update existing account objects and call save exactly once", -> + @instance.accountForId('A').syncState = 'invalid' + @instance.refreshHealthOfAccounts(['A', 'B']) + advanceClock() + expect(@instance.accountForId('A').syncState).toEqual('running') + expect(@instance._save.callCount).toBe(1) + + it "should ignore accountIds which do not exist locally when the request completes", -> + @instance.accountForId('A').syncState = 'invalid' + @instance.refreshHealthOfAccounts(['gone', 'A', 'B']) + advanceClock() + expect(@instance.accountForId('A').syncState).toEqual('running') + expect(@instance._save.callCount).toBe(1) + + it "should not stop if a single GET /account fails", -> + @instance.accountForId('B').syncState = 'invalid' + @instance.refreshHealthOfAccounts(['return-api-error', 'B']).catch (e) => + advanceClock() + expect(@instance.accountForId('B').syncState).toEqual('running') + expect(@instance._save.callCount).toBe(1) diff --git a/src/browser/application.es6 b/src/browser/application.es6 index bf44f5542..61e794a43 100644 --- a/src/browser/application.es6 +++ b/src/browser/application.es6 @@ -300,12 +300,12 @@ export default class Application extends EventEmitter { win.browserWindow.inspectElement(x, y); }); - this.on('application:add-account', (provider) => { + this.on('application:add-account', ({existingAccount} = {}) => { this.windowManager.ensureWindow(WindowManager.ONBOARDING_WINDOW, { title: "Add an Account", windowProps: { page: "account-choose", - pageData: {provider}, + pageData: {existingAccount}, }, }) }); diff --git a/src/flux/nylas-api.coffee b/src/flux/nylas-api.coffee index 0442618da..d71277528 100644 --- a/src/flux/nylas-api.coffee +++ b/src/flux/nylas-api.coffee @@ -250,24 +250,8 @@ class NylasAPI account = AccountStore.accounts().find (account) -> AccountStore.tokenForAccountId(account.id) is apiToken - email = "your mail provider" - email = account.emailAddress if account - - Actions.postNotification - type: 'error' - tag: '401' - sticky: true - message: "A mailbox action for #{email} could not be completed. You - may not be able to send or receive mail.", - icon: 'fa-sign-out' - actions: [{ - default: true - dismisses: true - label: 'Dismiss' - provider: account?.provider ? "" - id: '401:dismiss' - }] - + if account + Actions.updateAccount(account.id, {syncState: Account.SYNC_STATE_AUTH_FAILED}) return Promise.resolve() # Returns a Promise that resolves when any parsed out models (if any) diff --git a/src/flux/stores/account-store.coffee b/src/flux/stores/account-store.coffee index 55e25bc79..5fe510ba0 100644 --- a/src/flux/stores/account-store.coffee +++ b/src/flux/stores/account-store.coffee @@ -5,6 +5,7 @@ Account = require('../models/account').default Utils = require '../models/utils' DatabaseStore = require './database-store' keytar = require 'keytar' +NylasAPI = null configAccountsKey = "nylas.accounts" configVersionKey = "nylas.accountsVersion" @@ -25,6 +26,11 @@ class AccountStore extends NylasStore @listenTo Actions.updateAccount, @_onUpdateAccount @listenTo Actions.reorderAccount, @_onReorderAccount + if NylasEnv.isWorkWindow() and ['staging', 'production'].includes(NylasEnv.config.get('env')) + setTimeout( => + @refreshHealthOfAccounts(@_accounts.map((a) -> a.id)) + , 2000) + NylasEnv.config.onDidChange configVersionKey, (change) => # If we already have this version of the accounts config, it means we # are the ones who saved the change, and we don't need to reload. @@ -168,6 +174,20 @@ class AccountStore extends NylasStore @_save() Actions.focusDefaultMailboxPerspectiveForAccounts([account.id]) + refreshHealthOfAccounts: (accountIds) => + NylasAPI ?= require '../nylas-api' + Promise.all(accountIds.map (accountId) => + return NylasAPI.makeRequest({ + path: '/account', + accountId: accountId, + }).then (json) => + existing = @accountForId(accountId) + return unless existing # user may have deleted + existing.fromJSON(json) + ).finally => + @_caches = {} + @_save() + # Exposed Data # Private: Helper which caches the results of getter functions often needed