diff --git a/internal_packages/onboarding/lib/onboarding-helpers.es6 b/internal_packages/onboarding/lib/onboarding-helpers.es6 index 24ffe0846..fabcbc904 100644 --- a/internal_packages/onboarding/lib/onboarding-helpers.es6 +++ b/internal_packages/onboarding/lib/onboarding-helpers.es6 @@ -35,43 +35,34 @@ function base64url(inBuffer) { .replace(/\//g, '_'); // Convert '/' to '_' } -export async function makeGmailOAuthRequest(sessionKey, callback) { - const noauth = { - user: '', - pass: '', - sendImmediately: true, - }; +const NO_AUTH = { user: '', pass: '', sendImmediately: true }; + +export async function tokenRequestPollForGmail(sessionKey) { const remoteRequest = new NylasAPIRequest({ api: N1CloudAPI, options: { path: `/auth/gmail/token?key=${sessionKey}`, method: 'GET', - error: callback, - auth: noauth, + auth: NO_AUTH, }, }); - let remoteJSON = {} - try { - remoteJSON = await remoteRequest.run() - } catch (err) { - if (err.statusCode === 404) { - return - } - throw err - } + return remoteRequest.run() +} + +export async function authIMAPForGmail(tokenData) { const localRequest = new NylasAPIRequest({ api: NylasAPI, options: { path: `/auth`, method: 'POST', - auth: noauth, + auth: NO_AUTH, body: { - email: remoteJSON.email_address, - name: remoteJSON.name, + email: tokenData.email_address, + name: tokenData.name, provider: 'gmail', settings: { - xoauth2: remoteJSON.resolved_settings.xoauth2, - expiry_date: remoteJSON.resolved_settings.expiry_date, + xoauth2: tokenData.resolved_settings.xoauth2, + expiry_date: tokenData.resolved_settings.expiry_date, }, }, }, @@ -79,8 +70,8 @@ export async function makeGmailOAuthRequest(sessionKey, callback) { const localJSON = await localRequest.run() const account = Object.assign({}, localJSON); account.localToken = localJSON.account_token; - account.cloudToken = remoteJSON.account_token; - callback(null, account); + account.cloudToken = tokenData.account_token; + return account } export function buildGmailSessionKey() { diff --git a/internal_packages/onboarding/lib/page-account-settings-gmail.jsx b/internal_packages/onboarding/lib/page-account-settings-gmail.jsx index 2577ca908..da4a7180f 100644 --- a/internal_packages/onboarding/lib/page-account-settings-gmail.jsx +++ b/internal_packages/onboarding/lib/page-account-settings-gmail.jsx @@ -2,7 +2,8 @@ import React from 'react'; import {OAuthSignInPage} from 'nylas-component-kit'; import { - makeGmailOAuthRequest, + tokenRequestPollForGmail, + authIMAPForGmail, buildGmailSessionKey, buildGmailAuthURL, } from './onboarding-helpers'; @@ -31,14 +32,17 @@ export default class AccountSettingsPageGmail extends React.Component { render() { const {accountInfo} = this.props; const iconName = AccountTypes.find(a => a.type === accountInfo.type).headerIcon; + const goBack = () => OnboardingActions.moveToPreviousPage() return ( ); diff --git a/internal_packages/onboarding/stylesheets/onboarding.less b/internal_packages/onboarding/stylesheets/onboarding.less index 9353a7a7c..e9b90de8b 100644 --- a/internal_packages/onboarding/stylesheets/onboarding.less +++ b/internal_packages/onboarding/stylesheets/onboarding.less @@ -342,7 +342,7 @@ padding-bottom: 10px; } } -.page.account-setup.gmail { +.page.account-setup.google { .logo-container { padding-top: 160px; } diff --git a/src/components/oauth-signin-page.jsx b/src/components/oauth-signin-page.jsx index 8d92e5479..ee0dd0c76 100644 --- a/src/components/oauth-signin-page.jsx +++ b/src/components/oauth-signin-page.jsx @@ -1,25 +1,57 @@ import React from 'react'; import {ipcRenderer, shell} from 'electron'; +import {Actions} from 'nylas-exports' import {RetinaImg} from 'nylas-component-kit'; const clipboard = require('electron').clipboard - export default class OAuthSignInPage extends React.Component { static displayName = "OAuthSignInPage"; static propTypes = { - authUrl: React.PropTypes.string, - iconName: React.PropTypes.string, - makeRequest: React.PropTypes.func, + /** + * Step 1: Open a webpage in the user's browser letting them login on + * the native provider's website. We pass along a key and a redirect + * url to a Nylas-owned server + */ + providerAuthPageUrl: React.PropTypes.string, + + /** + * Step 2: Poll a Nylas server with this function looking for the key. + * Once users complete the auth successfully, Nylas servers will get + * the token and vend it back to us via this url. We need to poll + * since we don't know how long it'll take users to log in on their + * provider's website. + */ + tokenRequestPollFn: React.PropTypes.func, + + /** + * Once we have the token, we can use that to retrieve the full + * account credentials or establish a direct connection ourselves. + * Some Nylas backends vend all account credentials along with the + * token making this function unnecessary and a no-op. Nylas Mail + * local sync needs to use the returned OAuth token to establish an + * IMAP connection directly that may have its own set of failure + * cases. + */ + accountFromTokenFn: React.PropTypes.func, + + /** + * Called once we have successfully received the account data from + * `accountFromTokenFn` + */ onSuccess: React.PropTypes.func, - serviceName: React.PropTypes.string, + + onTryAgain: React.PropTypes.func, + iconName: React.PropTypes.string, sessionKey: React.PropTypes.string, + serviceName: React.PropTypes.string, }; constructor() { super() this.state = { + authStage: "initial", showAlternative: false, } } @@ -29,7 +61,7 @@ export default class OAuthSignInPage extends React.Component { // to URL. (400msec animation + 200msec to read) this._pollTimer = null; this._startTimer = setTimeout(() => { - shell.openExternal(this.props.authUrl); + shell.openExternal(this.props.providerAuthPageUrl); this.startPollingForResponse(); }, 600); setTimeout(() => { @@ -42,10 +74,20 @@ export default class OAuthSignInPage extends React.Component { if (this._pollTimer) clearTimeout(this._pollTimer); } + _handleError(err) { + this.setState({authStage: "error", errorMessage: err.message}) + NylasEnv.reportError(err) + Actions.recordUserEvent('Email Account Auth Failed', { + errorMessage: err.message, + provider: "gmail", + }) + } + startPollingForResponse() { let delay = 1000; let onWindowFocused = null; let poll = null; + this.setState({authStage: "polling"}) onWindowFocused = () => { delay = 1000; @@ -55,61 +97,93 @@ export default class OAuthSignInPage extends React.Component { } }; - poll = () => { - this.props.makeRequest(this.props.sessionKey, (err, json) => { - clearTimeout(this._pollTimer); - if (json) { - ipcRenderer.removeListener('browser-window-focus', onWindowFocused); - let body = json - if (json.body) { - body = json.body - } - this.props.onSuccess(body); - } else { - delay = Math.min(delay * 1.2, 10000); + poll = async () => { + clearTimeout(this._pollTimer); + try { + const tokenData = await this.props.tokenRequestPollFn(this.props.sessionKey) + ipcRenderer.removeListener('browser-window-focus', onWindowFocused); + this.fetchAccountDataWithToken(tokenData) + } catch (err) { + if (err.statusCode === 404) { + delay = Math.min(delay * 1.1, 3000); this._pollTimer = setTimeout(poll, delay); + } else { + ipcRenderer.removeListener('browser-window-focus', onWindowFocused); + this._handleError(err) } - }); + } } ipcRenderer.on('browser-window-focus', onWindowFocused); - this._pollTimer = setTimeout(poll, 5000); + this._pollTimer = setTimeout(poll, 3000); } + async fetchAccountDataWithToken(tokenData) { + try { + this.setState({authStage: "fetchingAccount"}) + const accountData = await this.props.accountFromTokenFn(tokenData); + this.props.onSuccess(accountData) + this.setState({authStage: "accountSuccess"}) + } catch (err) { + this._handleError(err) + } + } + + _renderHeader() { + const authStage = this.state.authStage; + if (authStage === "initial" || authStage === "polling") { + return (

+ Sign in with {this.props.serviceName} in
your browser. +

) + } else if (authStage === "fetchingAccount") { + return

Connecting to {this.props.serviceName}…

+ } else if (this.authStage === "accountSuccess") { + return

Connected to {this.props.serviceName}…

+ } + // Error + return (
+

Sorry, we had trouble logging you in

+
+

{this.state.errorMessage}

+

Please try again. If you continue to see this error contact support@nylas.com

+
+
) + } _renderAlternative() { let classnames = "input hidden" - if (this.state.showAlternative) { + if (this.state.authStage === "polling" && this.state.showAlternative) { classnames += " fadein" } return ( -
-
- Page didn't open? Paste this URL into your browser: -
- -
clipboard.writeText(this.props.authUrl)} - onMouseDown={() => this.setState({pressed: true})} - onMouseUp={() => this.setState({pressed: false})} - > - +
+
+ Page didn't open? Paste this URL into your browser: +
+ +
clipboard.writeText(this.props.providerAuthPageUrl)} + onMouseDown={() => this.setState({pressed: true})} + onMouseUp={() => this.setState({pressed: false})} + > + +
) } - render() { return (
@@ -120,12 +194,8 @@ export default class OAuthSignInPage extends React.Component { className="logo" />
-

- Sign in to {this.props.serviceName} in
your browser. -

-
- {this._renderAlternative()} -
+ {this._renderHeader()} + {this._renderAlternative()}
); }