Periodically refresh identity, show expired notice in top bar

This commit is contained in:
Ben Gotow 2016-05-27 12:03:53 -07:00
parent 5dc39efe98
commit 80c3c7b956
7 changed files with 136 additions and 35 deletions

View file

@ -1,6 +1,7 @@
/* eslint global-require: 0 */ /* eslint global-require: 0 */
import {AccountStore, Account, Actions, React} from 'nylas-exports' import {AccountStore, Account, Actions, React, IdentityStore} from 'nylas-exports'
import {RetinaImg} from 'nylas-component-kit' import {RetinaImg} from 'nylas-component-kit'
import {shell} from 'electron';
export default class AccountErrorHeader extends React.Component { export default class AccountErrorHeader extends React.Component {
static displayName = 'AccountErrorHeader'; static displayName = 'AccountErrorHeader';
@ -58,7 +59,15 @@ export default class AccountErrorHeader extends React.Component {
}); });
} }
renderErrorHeader(message, buttonName, actionCallback) { _onUpgrade = () => {
this.setState({buildingUpgradeURL: true});
IdentityStore.fetchSingleSignOnURL('/dashboard').then((url) => {
this.setState({buildingUpgradeURL: false});
shell.openExternal(url);
});
}
_renderErrorHeader(message, buttonName, actionCallback) {
return ( return (
<div className="account-error-header notifications-sticky"> <div className="account-error-header notifications-sticky">
<div <div
@ -84,28 +93,59 @@ export default class AccountErrorHeader extends React.Component {
) )
} }
_renderUpgradeHeader() {
return (
<div className="account-error-header notifications-sticky">
<div
className={"notifications-sticky-item notification-upgrade has-default-action"}
onClick={this._onUpgrade}
>
<RetinaImg
className="icon"
name="ic-upgrade.png"
mode={RetinaImg.Mode.ContentIsMask}
/>
<div className="message">
Your 30-day trial has expired and we've paused your mailboxes. Upgrade today to continue using N1!
</div>
<a className="action refresh" onClick={this._onCheckAgain}>
{this.state.refreshing ? "Checking..." : "Check Again"}
</a>
<a className="action default" onClick={this._onUpgrade}>
{this.state.buildingUpgradeURL ? "Please wait..." : "Upgrade to Nylas Pro..."}
</a>
</div>
</div>
)
}
render() { render() {
const errorAccounts = this.state.accounts.filter(a => a.hasSyncStateError()); const errorAccounts = this.state.accounts.filter(a => a.hasSyncStateError());
const trialExpiredAccounts = errorAccounts.filter(a => a.trialExpirationDate && (a.trialExpirationDate < new Date()))
if (trialExpiredAccounts.length > 0 || true) {
return this._renderUpgradeHeader(trialExpiredAccounts)
}
if (errorAccounts.length === 1) { if (errorAccounts.length === 1) {
const account = errorAccounts[0]; const account = errorAccounts[0];
switch (account.syncState) { switch (account.syncState) {
case Account.SYNC_STATE_AUTH_FAILED: case Account.SYNC_STATE_AUTH_FAILED:
return this.renderErrorHeader( return this._renderErrorHeader(
`Nylas N1 can no longer authenticate with ${account.emailAddress}. Click here to reconnect.`, `Nylas N1 can no longer authenticate with ${account.emailAddress}. Click here to reconnect.`,
"Reconnect", "Reconnect",
() => this._reconnect(account)); () => this._reconnect(account));
case Account.SYNC_STATE_STOPPED: case Account.SYNC_STATE_STOPPED:
return this.renderErrorHeader( return this._renderErrorHeader(
`The cloud sync for ${account.emailAddress} has been disabled. You will `The cloud sync for ${account.emailAddress} has been disabled. You will
not be able to send or receive mail. Please contact Nylas support.`, not be able to send or receive mail. Please contact Nylas support.`,
"Contact support", "Contact support",
() => this._contactSupport()); () => this._contactSupport());
default: default:
return this.renderErrorHeader( return this._renderErrorHeader(
`Nylas encountered an error while syncing mail for ${account.emailAddress} - we're `Nylas encountered an error while syncing mail for ${account.emailAddress} - we're
looking into it. Contact Nylas support for details.`, looking into it. Contact Nylas support for details.`,
"Contact support", "Contact support",
@ -113,7 +153,7 @@ export default class AccountErrorHeader extends React.Component {
} }
} }
if (errorAccounts.length > 1) { if (errorAccounts.length > 1) {
return this.renderErrorHeader("Several of your accounts are having issues. " + return this._renderErrorHeader("Several of your accounts are having issues. " +
"You will not be able to send or receive mail. Click here to manage your accounts.", "You will not be able to send or receive mail. Click here to manage your accounts.",
"Open preferences", "Open preferences",
() => this._openPreferences()); () => this._openPreferences());

View file

@ -135,14 +135,15 @@
.notification-developer { .notification-developer {
background-color: #615396; background-color: #615396;
} }
.notification-upgrade {
background-image: -webkit-linear-gradient(bottom, #429E91, #40b1ac);
img { background-color: @text-color-inverse; }
}
.notification-error { .notification-error {
background: linear-gradient(to top, darken(@background-color-error, 4%) 0%, @background-color-error 100%); background: linear-gradient(to top, darken(@background-color-error, 4%) 0%, @background-color-error 100%);
border-color: @background-color-error; border-color: @background-color-error;
color: @color-error; color: @color-error;
} }
.notification-success {
border-color: @background-color-success;
}
.notification-offline { .notification-offline {
background: linear-gradient(to top, darken(#CC9900, 4%) 0%, #CC9900 100%); background: linear-gradient(to top, darken(#CC9900, 4%) 0%, #CC9900 100%);
border-color: darken(#CC9900, 5%); border-color: darken(#CC9900, 5%);

View file

@ -1,10 +1,11 @@
import React from 'react'; import React from 'react';
import {Actions, NylasAPI, IdentityStore} from 'nylas-exports'; import {Actions, IdentityStore} from 'nylas-exports';
import {RetinaImg} from 'nylas-component-kit'; import {RetinaImg} from 'nylas-component-kit';
import {shell} from 'electron';
class OpenIdentityPageButton extends React.Component { class OpenIdentityPageButton extends React.Component {
static propTypes = { static propTypes = {
destination: React.PropTypes.string, path: React.PropTypes.string,
label: React.PropTypes.string, label: React.PropTypes.string,
img: React.PropTypes.string, img: React.PropTypes.string,
} }
@ -17,13 +18,10 @@ class OpenIdentityPageButton extends React.Component {
} }
_onClick = () => { _onClick = () => {
this.setState({loading: true}); IdentityStore.fetchSingleSignOnURL(this.props.path).then((url) => {
const identity = IdentityStore.identity(); this.setState({loading: false});
if (!identity) { return } shell.openExternal(url);
NylasAPI.navigateToBillingSite() });
.then(() => {
this.setState({loading: false})
})
} }
render() { render() {
@ -37,7 +35,7 @@ class OpenIdentityPageButton extends React.Component {
} }
if (this.props.img) { if (this.props.img) {
return ( return (
<div className="btn"> <div className="btn" onClick={this._onClick}>
<RetinaImg name={this.props.img} mode={RetinaImg.Mode.ContentPreserve} /> <RetinaImg name={this.props.img} mode={RetinaImg.Mode.ContentPreserve} />
&nbsp;&nbsp;{this.props.label} &nbsp;&nbsp;{this.props.label}
</div> </div>
@ -92,7 +90,7 @@ class PreferencesIdentity extends React.Component {
<div className="name">{firstname} {lastname}</div> <div className="name">{firstname} {lastname}</div>
<div className="email">{email}</div> <div className="email">{email}</div>
<div className="identity-actions"> <div className="identity-actions">
<OpenIdentityPageButton label="Account Details" destination="/billing" /> <OpenIdentityPageButton label="Account Details" path="/dashboard" />
<div className="btn" onClick={() => Actions.logoutNylasIdentity()}>Sign Out</div> <div className="btn" onClick={() => Actions.logoutNylasIdentity()}>Sign Out</div>
</div> </div>
</div> </div>
@ -102,7 +100,7 @@ class PreferencesIdentity extends React.Component {
<OpenIdentityPageButton <OpenIdentityPageButton
img="ic-upgrade.png" img="ic-upgrade.png"
label="Upgrade to Nylas Pro" label="Upgrade to Nylas Pro"
destination="/billing" path="/dashboard#subscription"
/> />
</div> </div>
</div> </div>

View file

@ -1,7 +1,6 @@
React = require 'react' React = require 'react'
ReactDOM = require 'react-dom' ReactDOM = require 'react-dom'
{Utils, {Utils,
NylasAPI,
RegExpUtils, RegExpUtils,
IdentityStore, IdentityStore,
SearchableComponentMaker, SearchableComponentMaker,
@ -163,7 +162,9 @@ class EventedIFrame extends React.Component
# If this is a link to our billing site, attempt single sign on instead of # If this is a link to our billing site, attempt single sign on instead of
# just following the link directly # just following the link directly
if rawHref.startsWith(IdentityStore.URLRoot) if rawHref.startsWith(IdentityStore.URLRoot)
NylasAPI.navigateToBillingSite(IdentityStore.identity(), '/billing') path = rawHref.split(IdentityStore.URLRoot).pop()
IdentityStore.fetchSingleSignOnURL(IdentityStore.identity(), path).then (href) =>
NylasEnv.windowEventHandler.openLink(href: href, metaKey: e.metaKey)
return return
# It's important to send the raw `href` here instead of the target. # It's important to send the raw `href` here instead of the target.

View file

@ -58,26 +58,27 @@ export default class Account extends ModelWithMetadata {
}), }),
label: Attributes.String({ label: Attributes.String({
queryable: false,
modelKey: 'label', modelKey: 'label',
}), }),
aliases: Attributes.Object({ aliases: Attributes.Object({
queryable: false,
modelKey: 'aliases', modelKey: 'aliases',
}), }),
defaultAlias: Attributes.Object({ defaultAlias: Attributes.Object({
queryable: false,
modelKey: 'defaultAlias', modelKey: 'defaultAlias',
jsonKey: 'default_alias', jsonKey: 'default_alias',
}), }),
syncState: Attributes.String({ syncState: Attributes.String({
queryable: false,
modelKey: 'syncState', modelKey: 'syncState',
jsonKey: 'sync_state', jsonKey: 'sync_state',
}), }),
trialExpirationDate: Attributes.DateTime({
modelKey: 'trialExpirationDate',
jsonKey: 'trial_expiration_date',
}),
}); });
constructor(args) { constructor(args) {

View file

@ -115,6 +115,9 @@ class NylasAPI
if NylasEnv.getLoadSettings().isSpec if NylasEnv.getLoadSettings().isSpec
return Promise.resolve() return Promise.resolve()
NylasAPIRequest ?= require('./nylas-api-request').default
req = new NylasAPIRequest(@, options)
success = (body) => success = (body) =>
if options.beforeProcessing if options.beforeProcessing
body = options.beforeProcessing(body) body = options.beforeProcessing(body)
@ -124,19 +127,19 @@ class NylasAPI
Promise.resolve(body) Promise.resolve(body)
error = (err) => error = (err) =>
{url, auth, returnsModel} = req.options
handlePromise = Promise.resolve() handlePromise = Promise.resolve()
if err.response if err.response
if err.response.statusCode is 404 and options.returnsModel if err.response.statusCode is 404 and returnsModel
handlePromise = @_handleModel404(options.url) handlePromise = @_handleModel404(url)
if err.response.statusCode in [401, 403] if err.response.statusCode in [401, 403]
handlePromise = @_handleAuthenticationFailure(options.url, options.auth?.user) handlePromise = @_handleAuthenticationFailure(url, auth?.user)
if err.response.statusCode is 400 if err.response.statusCode is 400
NylasEnv.reportError(err) NylasEnv.reportError(err)
handlePromise.finally -> handlePromise.finally ->
Promise.reject(err) Promise.reject(err)
NylasAPIRequest ?= require('./nylas-api-request').default
req = new NylasAPIRequest(@, options)
req.run().then(success, error) req.run().then(success, error)
longConnection: (opts) -> longConnection: (opts) ->

View file

@ -2,6 +2,7 @@ import NylasStore from 'nylas-store';
import Actions from '../actions'; import Actions from '../actions';
import keytar from 'keytar'; import keytar from 'keytar';
import {ipcRenderer} from 'electron'; import {ipcRenderer} from 'electron';
import request from 'request';
const configIdentityKey = "nylas.identity"; const configIdentityKey = "nylas.identity";
const keytarServiceName = 'Nylas'; const keytarServiceName = 'Nylas';
@ -24,6 +25,11 @@ class IdentityStore extends NylasStore {
this.trigger(); this.trigger();
}); });
this._loadIdentity(); this._loadIdentity();
if (NylasEnv.isWorkWindow() && ['staging', 'production'].includes(NylasEnv.config.get('env'))) {
setInterval(this.refreshStatus, 1000 * 60 * 60);
this.refreshStatus();
}
} }
_loadIdentity() { _loadIdentity() {
@ -42,7 +48,58 @@ class IdentityStore extends NylasStore {
} }
trialDaysRemaining() { trialDaysRemaining() {
return this._trialDaysRemaining; return 14;
}
refreshStatus = () => {
request({
method: 'GET',
url: `${this.URLRoot}/n1/user`,
auth: {
username: this._identity.token,
password: '',
sendImmediately: true,
},
}, (error, response = {}, body) => {
if (response.statusCode === 200) {
try {
const nextIdentity = Object.assign({}, this._identity, JSON.parse(body));
this._onSetNylasIdentity(nextIdentity)
} catch (err) {
NylasEnv.reportError("IdentityStore.refreshStatus: invalid JSON in response body.")
}
}
});
}
fetchSingleSignOnURL(path) {
if (!this._identity) {
return Promise.reject(new Error("fetchSingleSignOnURL: no identity set."));
}
if (!path.startsWith('/')) {
return Promise.reject(new Error("fetchSingleSignOnURL: path must start with a leading slash."));
}
return new Promise((resolve) => {
request({
method: 'POST',
url: `${this.URLRoot}/n1/login-link`,
json: true,
body: {
next_path: path,
account_token: this._identity.token,
},
}, (error, response = {}, body) => {
if (error || !body.startsWith('http')) {
// Single-sign on attempt failed. Rather than churn the user right here,
// at least try to open the page directly in the browser.
resolve(`${this.URLRoot}${path}`);
} else {
resolve(body);
}
});
});
} }
_onLogoutNylasIdentity = () => { _onLogoutNylasIdentity = () => {