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 (
- )
+
+ )
}
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