From 80c3c7b9564ff79b5727f6812620b9436f35f963 Mon Sep 17 00:00:00 2001 From: Ben Gotow Date: Fri, 27 May 2016 12:03:53 -0700 Subject: [PATCH] Periodically refresh identity, show expired notice in top bar --- .../lib/headers/account-error-header.jsx | 54 ++++++++++++++--- .../stylesheets/notifications.less | 9 +-- .../lib/tabs/preferences-identity.jsx | 22 ++++--- src/components/evented-iframe.cjsx | 5 +- src/flux/models/account.es6 | 9 +-- src/flux/nylas-api.coffee | 13 ++-- src/flux/stores/identity-store.es6 | 59 ++++++++++++++++++- 7 files changed, 136 insertions(+), 35 deletions(-) diff --git a/internal_packages/notifications/lib/headers/account-error-header.jsx b/internal_packages/notifications/lib/headers/account-error-header.jsx index 1c713b945..f719a79fe 100644 --- a/internal_packages/notifications/lib/headers/account-error-header.jsx +++ b/internal_packages/notifications/lib/headers/account-error-header.jsx @@ -1,6 +1,7 @@ /* 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 {shell} from 'electron'; export default class AccountErrorHeader extends React.Component { 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 (
+
+ +
+ Your 30-day trial has expired and we've paused your mailboxes. Upgrade today to continue using N1! +
+ + {this.state.refreshing ? "Checking..." : "Check Again"} + + + {this.state.buildingUpgradeURL ? "Please wait..." : "Upgrade to Nylas Pro..."} + +
+
+ ) + } + render() { 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) { const account = errorAccounts[0]; switch (account.syncState) { - 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.`, "Reconnect", () => this._reconnect(account)); case Account.SYNC_STATE_STOPPED: - return this.renderErrorHeader( + return this._renderErrorHeader( `The cloud sync for ${account.emailAddress} has been disabled. You will not be able to send or receive mail. Please contact Nylas support.`, "Contact support", () => this._contactSupport()); default: - return this.renderErrorHeader( + return this._renderErrorHeader( `Nylas encountered an error while syncing mail for ${account.emailAddress} - we're looking into it. Contact Nylas support for details.`, "Contact support", @@ -113,7 +153,7 @@ export default class AccountErrorHeader extends React.Component { } } 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.", "Open preferences", () => this._openPreferences()); diff --git a/internal_packages/notifications/stylesheets/notifications.less b/internal_packages/notifications/stylesheets/notifications.less index 562b3806c..c9b6713b2 100644 --- a/internal_packages/notifications/stylesheets/notifications.less +++ b/internal_packages/notifications/stylesheets/notifications.less @@ -135,14 +135,15 @@ .notification-developer { background-color: #615396; } + .notification-upgrade { + background-image: -webkit-linear-gradient(bottom, #429E91, #40b1ac); + img { background-color: @text-color-inverse; } + } .notification-error { background: linear-gradient(to top, darken(@background-color-error, 4%) 0%, @background-color-error 100%); border-color: @background-color-error; color: @color-error; } - .notification-success { - border-color: @background-color-success; - } .notification-offline { background: linear-gradient(to top, darken(#CC9900, 4%) 0%, #CC9900 100%); border-color: darken(#CC9900, 5%); @@ -151,7 +152,7 @@ .notifications-sticky-item { display:flex; font-size: @font-size-base; - color:@text-color-inverse; + color: @text-color-inverse; border-bottom:1px solid rgba(0,0,0,0.25); padding-left: @padding-base-horizontal; line-height: @line-height-base * 1.5; diff --git a/internal_packages/preferences/lib/tabs/preferences-identity.jsx b/internal_packages/preferences/lib/tabs/preferences-identity.jsx index 48d679a74..e437dcb78 100644 --- a/internal_packages/preferences/lib/tabs/preferences-identity.jsx +++ b/internal_packages/preferences/lib/tabs/preferences-identity.jsx @@ -1,10 +1,11 @@ import React from 'react'; -import {Actions, NylasAPI, IdentityStore} from 'nylas-exports'; +import {Actions, IdentityStore} from 'nylas-exports'; import {RetinaImg} from 'nylas-component-kit'; +import {shell} from 'electron'; class OpenIdentityPageButton extends React.Component { static propTypes = { - destination: React.PropTypes.string, + path: React.PropTypes.string, label: React.PropTypes.string, img: React.PropTypes.string, } @@ -17,13 +18,10 @@ class OpenIdentityPageButton extends React.Component { } _onClick = () => { - this.setState({loading: true}); - const identity = IdentityStore.identity(); - if (!identity) { return } - NylasAPI.navigateToBillingSite() - .then(() => { - this.setState({loading: false}) - }) + IdentityStore.fetchSingleSignOnURL(this.props.path).then((url) => { + this.setState({loading: false}); + shell.openExternal(url); + }); } render() { @@ -37,7 +35,7 @@ class OpenIdentityPageButton extends React.Component { } if (this.props.img) { return ( -
+
  {this.props.label}
@@ -92,7 +90,7 @@ class PreferencesIdentity extends React.Component {
{firstname} {lastname}
{email}
- +
Actions.logoutNylasIdentity()}>Sign Out
@@ -102,7 +100,7 @@ class PreferencesIdentity extends React.Component {
diff --git a/src/components/evented-iframe.cjsx b/src/components/evented-iframe.cjsx index 3e85c6c08..69afdb311 100644 --- a/src/components/evented-iframe.cjsx +++ b/src/components/evented-iframe.cjsx @@ -1,7 +1,6 @@ React = require 'react' ReactDOM = require 'react-dom' {Utils, - NylasAPI, RegExpUtils, IdentityStore, 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 # just following the link directly 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 # It's important to send the raw `href` here instead of the target. diff --git a/src/flux/models/account.es6 b/src/flux/models/account.es6 index 0579d2e2a..89c569292 100644 --- a/src/flux/models/account.es6 +++ b/src/flux/models/account.es6 @@ -58,26 +58,27 @@ export default class Account extends ModelWithMetadata { }), label: Attributes.String({ - queryable: false, modelKey: 'label', }), aliases: Attributes.Object({ - queryable: false, modelKey: 'aliases', }), defaultAlias: Attributes.Object({ - queryable: false, modelKey: 'defaultAlias', jsonKey: 'default_alias', }), syncState: Attributes.String({ - queryable: false, modelKey: 'syncState', jsonKey: 'sync_state', }), + + trialExpirationDate: Attributes.DateTime({ + modelKey: 'trialExpirationDate', + jsonKey: 'trial_expiration_date', + }), }); constructor(args) { diff --git a/src/flux/nylas-api.coffee b/src/flux/nylas-api.coffee index c62bec5ac..586e62659 100644 --- a/src/flux/nylas-api.coffee +++ b/src/flux/nylas-api.coffee @@ -115,6 +115,9 @@ class NylasAPI if NylasEnv.getLoadSettings().isSpec return Promise.resolve() + NylasAPIRequest ?= require('./nylas-api-request').default + req = new NylasAPIRequest(@, options) + success = (body) => if options.beforeProcessing body = options.beforeProcessing(body) @@ -124,19 +127,19 @@ class NylasAPI Promise.resolve(body) error = (err) => + {url, auth, returnsModel} = req.options + handlePromise = Promise.resolve() if err.response - if err.response.statusCode is 404 and options.returnsModel - handlePromise = @_handleModel404(options.url) + if err.response.statusCode is 404 and returnsModel + handlePromise = @_handleModel404(url) 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 NylasEnv.reportError(err) handlePromise.finally -> Promise.reject(err) - NylasAPIRequest ?= require('./nylas-api-request').default - req = new NylasAPIRequest(@, options) req.run().then(success, error) longConnection: (opts) -> diff --git a/src/flux/stores/identity-store.es6 b/src/flux/stores/identity-store.es6 index b04f06767..9ec86a3ab 100644 --- a/src/flux/stores/identity-store.es6 +++ b/src/flux/stores/identity-store.es6 @@ -2,6 +2,7 @@ import NylasStore from 'nylas-store'; import Actions from '../actions'; import keytar from 'keytar'; import {ipcRenderer} from 'electron'; +import request from 'request'; const configIdentityKey = "nylas.identity"; const keytarServiceName = 'Nylas'; @@ -24,6 +25,11 @@ class IdentityStore extends NylasStore { this.trigger(); }); this._loadIdentity(); + + if (NylasEnv.isWorkWindow() && ['staging', 'production'].includes(NylasEnv.config.get('env'))) { + setInterval(this.refreshStatus, 1000 * 60 * 60); + this.refreshStatus(); + } } _loadIdentity() { @@ -42,7 +48,58 @@ class IdentityStore extends NylasStore { } 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 = () => {