feat(pro): New Nylas identity provider, onboarding and auth

commit 50d0cfb87c
Author: Ben Gotow <bengotow@gmail.com>
Date:   Fri May 27 14:01:49 2016 -0700

    IdentityStore conveniene methods for subscription state

commit 80c3c7b956
Author: Ben Gotow <bengotow@gmail.com>
Date:   Fri May 27 12:03:53 2016 -0700

    Periodically refresh identity, show expired notice in top bar

commit 5dc39efe98
Merge: 4c4f463 906ea74
Author: Juan Tejada <juans.tejada@gmail.com>
Date:   Thu May 26 15:17:46 2016 -0700

    Merge branch 'bengotow/n1-pro' of github.com:nylas/N1 into bengotow/n1-pro

commit 4c4f463f4b
Author: Juan Tejada <juans.tejada@gmail.com>
Date:   Thu May 26 15:16:48 2016 -0700

    Hijack links inside email that go to billing site and add SSO to them

commit 906ea74807
Author: Ben Gotow <bengotow@gmail.com>
Date:   Thu May 26 12:02:29 2016 -0700

    Add custom welcome page for upgrading users

commit 2ba9aedfe9
Author: Juan Tejada <juans.tejada@gmail.com>
Date:   Wed May 25 17:27:12 2016 -0700

    Add styling to Subscription tab in prefs

commit 384433a338
Author: Ben Gotow <bengotow@gmail.com>
Date:   Wed May 25 16:21:18 2016 -0700

    Add better style reset, more IdentityStore changes

commit c4f9dfb4e4
Author: Ben Gotow <bengotow@gmail.com>
Date:   Wed May 25 15:29:41 2016 -0700

    Add subscription tab

commit bd4c25405a
Author: Ben Gotow <bengotow@gmail.com>
Date:   Wed May 25 14:18:40 2016 -0700

    Point to billing-staging for now

commit 578e808bfc
Author: Ben Gotow <bengotow@gmail.com>
Date:   Wed May 25 13:30:13 2016 -0700

    Rename account helpers > onboarding helpers

commit dfea0a9861
Author: Ben Gotow <bengotow@gmail.com>
Date:   Wed May 25 13:26:46 2016 -0700

    A few minor fixes

commit 7110217fd4
Author: Ben Gotow <bengotow@gmail.com>
Date:   Wed May 25 12:58:21 2016 -0700

    feat(onboarding): Nylas Pro onboarding overhaul

    Summary:
    Rip out all invite-related code

    Enable Templates and Translate by default

    Scrub packages page, unused code in onboarding pkg

    Remove resizing

    New onboarding screens

    IMAP provider list, validation

    Call success with response object as well

    Renaming and tweaks

    Test Plan: No tests yet

    Reviewers: evan, juan, jackie

    Differential Revision: https://phab.nylas.com/D2985

commit dc9ea45ca9
Author: Ben Gotow <bengotow@gmail.com>
Date:   Wed May 25 12:52:39 2016 -0700

    Renaming and tweaks

commit 5ca4cd31ce
Author: Ben Gotow <bengotow@gmail.com>
Date:   Wed May 25 11:03:57 2016 -0700

    Call success with response object as well

commit 45f14f9b00
Author: Ben Gotow <bengotow@gmail.com>
Date:   Tue May 24 18:26:38 2016 -0700

    IMAP provider list, validation

commit c6ca124e6e
Author: Ben Gotow <bengotow@gmail.com>
Date:   Sat May 21 11:14:44 2016 -0700

    New onboarding screens

commit dad918d926
Author: Ben Gotow <bengotow@gmail.com>
Date:   Thu May 19 16:37:31 2016 -0700

    Remove resizing

commit ecb1a569e2
Author: Ben Gotow <bengotow@gmail.com>
Date:   Thu May 19 16:36:04 2016 -0700

    Scrub packages page, unused code in onboarding pkg

commit 3e0a44156c
Author: Ben Gotow <bengotow@gmail.com>
Date:   Thu May 19 16:33:12 2016 -0700

    Enable Templates and Translate by default

commit 0d218bc86f
Author: Ben Gotow <bengotow@gmail.com>
Date:   Thu May 19 16:30:47 2016 -0700

    Rip out all invite-related code
This commit is contained in:
Ben Gotow 2016-05-27 14:05:27 -07:00
parent e103809b34
commit c6354feb41
70 changed files with 4147 additions and 2357 deletions

View file

@ -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';
@ -12,23 +13,25 @@ export default class AccountErrorHeader extends React.Component {
componentDidMount() {
this.mounted = true;
this.unsubscribe = AccountStore.listen(() => this._onAccountsChanged());
this.unsubscribers = [
AccountStore.listen(() => this.setState(this.getStateFromStores())),
IdentityStore.listen(() => this.setState(this.getStateFromStores())),
];
}
componentWillUnmount() {
this.mounted = false;
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = null;
for (const unsub of this.unsubscribers) {
unsub();
}
this.unsubscribers = null;
}
getStateFromStores() {
return {accounts: AccountStore.accounts()}
}
_onAccountsChanged() {
this.setState(this.getStateFromStores())
return {
accounts: AccountStore.accounts(),
subscriptionState: IdentityStore.subscriptionState(),
}
}
_reconnect(existingAccount) {
@ -42,7 +45,6 @@ export default class AccountErrorHeader extends React.Component {
}
_contactSupport() {
const {shell} = require("electron");
shell.openExternal("https://support.nylas.com/hc/en-us/requests/new");
}
@ -58,7 +60,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 (
<div className="account-error-header notifications-sticky">
<div
@ -84,28 +94,68 @@ 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">
{
(this.state.subscriptionState === IdentityStore.State.Lapsed) ? (
"Your subscription has expired and we've paused your mailboxes. Re-new your subscription to continue using N1!"
) : (
"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() {
const errorAccounts = this.state.accounts.filter(a => a.hasSyncStateError());
const {accounts, subscriptionState} = this.state;
const subscriptionNeeded = accounts.find(a =>
a.subscriptionRequiredAfter && (a.subscriptionRequiredAfter < new Date())
)
if (subscriptionNeeded && (subscriptionState !== IdentityStore.State.Valid)) {
return this._renderUpgradeHeader()
}
const errorAccounts = accounts.filter(a => a.hasSyncStateError());
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 +163,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());

View file

@ -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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

After

Width:  |  Height:  |  Size: 839 KiB

View file

@ -1,107 +0,0 @@
React = require 'react'
_ = require 'underscore'
{RetinaImg} = require 'nylas-component-kit'
{EdgehillAPI, Utils, Actions} = require 'nylas-exports'
OnboardingActions = require './onboarding-actions'
Providers = require './account-types'
url = require 'url'
class AccountChoosePage extends React.Component
@displayName: "AccountChoosePage"
componentDidMount: ->
{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)
render: =>
<div className="page account-choose">
<div className="quit" onClick={ -> OnboardingActions.closeWindow() }>
<RetinaImg name="onboarding-close.png" mode={RetinaImg.Mode.ContentPreserve}/>
</div>
<div className="caption" style={marginTop: 33, marginBottom:25}>Select your email provider:</div>
{@_renderProviders()}
</div>
_renderProviders: ->
return Providers.map (provider) =>
<div className={"provider "+provider.name} key={provider.name} onClick={=>@_onChooseProvider(provider)}>
<div className="icon-container">
<RetinaImg name={provider.icon} mode={RetinaImg.Mode.ContentPreserve} className="icon"/>
</div>
<span className="provider-name">{provider.displayName}</span>
</div>
_onChooseProvider: (provider) =>
Actions.recordUserEvent('Auth Flow Started', {
provider: provider.name
})
if provider.name is 'gmail'
# Show the "Sign in to Gmail" prompt for a moment before actually bouncing
# to Gmail. (400msec animation + 200msec to read)
_.delay =>
@_onBounceToGmail(provider)
, 600
OnboardingActions.moveToPage("account-settings", Object.assign({provider}, @props.pageData))
_base64url: (buf) ->
# Python-style urlsafe_b64encode
buf.toString('base64')
.replace(/\+/g, '-') # Convert '+' to '-'
.replace(/\//g, '_') # Convert '/' to '_'
_onBounceToGmail: (provider) =>
crypto = require 'crypto'
# Client key is used for polling. Requirements are that it not be guessable
# and that it never collides with an active key (keys are active only between
# initiating gmail auth and successfully requesting the account data once.
provider.clientKey = @_base64url(crypto.randomBytes(40))
# Encryption key is used to AES encrypt the account data during storage on the
# server.
provider.encryptionKey = crypto.randomBytes(24)
provider.encryptionIv = crypto.randomBytes(16)
code = NylasEnv.config.get('invitationCode') || ''
state = [provider.clientKey,@_base64url(provider.encryptionKey),@_base64url(provider.encryptionIv),code].join(',')
# Use a different app for production and development.
env = NylasEnv.config.get('env') || 'production'
google_client_id = '372024217839-cdsnrrqfr4d6b4gmlqepd7v0n0l0ip9q.apps.googleusercontent.com'
if env != 'production'
google_client_id = '529928329786-e5foulo1g9kiej2h9st9sb0f4dt96s6v.apps.googleusercontent.com'
googleUrl = url.format({
protocol: 'https'
host: 'accounts.google.com/o/oauth2/auth'
query:
response_type: 'code'
state: state
client_id: google_client_id
redirect_uri: "#{EdgehillAPI.APIRoot}/oauth/google/callback"
access_type: 'offline'
scope: 'https://www.googleapis.com/auth/userinfo.email \
https://www.googleapis.com/auth/userinfo.profile \
https://mail.google.com/ \
https://www.google.com/m8/feeds \
https://www.googleapis.com/auth/calendar'
prompt: 'consent'
})
{shell} = require 'electron'
shell.openExternal(googleUrl)
module.exports = AccountChoosePage

View file

@ -1,470 +0,0 @@
React = require 'react'
ReactDOM = require 'react-dom'
_ = require 'underscore'
{ipcRenderer, dialog, remote} = require 'electron'
{RetinaImg} = require 'nylas-component-kit'
{RegExpUtils, EdgehillAPI, NylasAPI, APIError, Actions, AccountStore} = require 'nylas-exports'
OnboardingActions = require './onboarding-actions'
NylasApiEnvironmentStore = require './nylas-api-environment-store'
Providers = require './account-types'
class AccountSettingsPage extends React.Component
@displayName: "AccountSettingsPage"
constructor: (@props) ->
@state =
provider: @props.pageData.provider
settings: {}
fields: {}
pageNumber: 0
errorFieldNames: []
errorMessage: null
show_advanced: false
@props.pageData.provider.settings.forEach (field) =>
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'
pollAttemptId = 0
done = false
# polling with capped exponential backoff
delay = 1000
tries = 0
poll = (id,initial_delay) =>
_retry = =>
tries++
@_pollForGmailAccount((account_data) =>
if account_data?
done = true
{data} = account_data
account = JSON.parse(data)
@_onAccountReceived(account)
else if tries < 20 and id is pollAttemptId
setTimeout(_retry, delay)
delay *= 1.2 # exponential backoff
)
setTimeout(_retry,initial_delay)
ipcRenderer.on('browser-window-focus', ->
if not done # hack to deactivate this listener when done
pollAttemptId++
poll(pollAttemptId,0)
)
poll(pollAttemptId,5000)
componentWillUnmount: =>
clearTimeout(@_resizeTimer) if @_resizeTimer
@_resizeTimer = null
render: ->
<div className="page account-setup">
<div className="logo-container">
<RetinaImg
name={@state.provider.header_icon}
mode={RetinaImg.Mode.ContentPreserve}
className="logo"/>
</div>
{@_renderTitle()}
<div className="back" onClick={@_onMoveToPrevPage}>
<RetinaImg
name="onboarding-back.png"
mode={RetinaImg.Mode.ContentPreserve}/>
</div>
{@_renderErrorMessage()}
<form className="settings">
{@_renderFields()}
{@_renderSettings()}
{@_renderButton()}
</form>
</div>
componentDidMount: =>
@_applyFocus()
componentDidUpdate: =>
@_applyFocus()
_applyFocus: =>
firstInput = ReactDOM.findDOMNode(@).querySelector('input')
anyInputFocused = document.activeElement and document.activeElement.nodeName is 'INPUT'
if firstInput and not anyInputFocused
firstInput.focus()
_onSettingsChanged: (event) =>
# NOTE: This code is largely duplicated in _onValueChanged. TODO Fix!
{field, format} = event.target.dataset
intFormatter = (a) ->
i = parseInt(a)
if isNaN(i) then "" else i
formatter = if format is 'integer' then intFormatter else (a) -> a
settings = @state.settings
if event.target.type is 'checkbox'
settings[field] = event.target.checked
else
settings[field] = formatter(event.target.value)
settingField = _.findWhere(@state.provider.settings, {name: field})
# If the field defines an isValid method, try to validate
# the input.
if settingField
valueIsValid = not settingField.isValid? or settingField.isValid(event.target.value)
valueIsPresent = event.target.value and event.target.value.length > 0
valueIsRequired = settingField.required is true
if (not valueIsPresent and valueIsRequired) or (valueIsPresent and not valueIsValid)
errorFields = _.uniq(@state.errorFieldNames.concat([field]))
else
errorFields = _.uniq(_.without(@state.errorFieldNames, field))
@setState({errorFieldNames: errorFields})
@setState({settings})
_noFormErrors: =>
allFields = @state.provider.fields.concat(@state.provider.settings || [])
fieldsOnThisPage = allFields.filter(@_fieldOnCurrentPage)
fieldNames = _.pluck(fieldsOnThisPage, 'name')
return _.intersection(fieldNames, @state.errorFieldNames).length == 0
_fieldRequired: (f) =>
return f?.required == true
_allRequiredFieldsFilled: =>
allFields = @state.provider.fields.concat(@state.provider.settings || [])
requiredFields = allFields.filter(@_fieldOnCurrentPage).filter(@_fieldRequired)
fields = _.extend({}, @state.fields, @state.settings)
for field in requiredFields
fieldName = field['name']
if not (fieldName of fields) or fields[fieldName] == ''
return false
return true
_onValueChanged: (event) =>
# NOTE: This code is largely duplicated in _onSettingsChanged. TODO Fix!
field = event.target.dataset.field
fields = @state.fields
fields[field] = event.target.value
providerField = _.find(@state.provider.fields, ((e) -> return e['name'] == field))
# If the field defines an isValid method, try to validate
# the input.
if providerField
valueIsValid = not providerField.isValid? or providerField.isValid(event.target.value)
valueIsPresent = event.target.value and event.target.value.length > 0
valueIsRequired = providerField.required is true
if (not valueIsPresent and valueIsRequired) or (valueIsPresent and not valueIsValid)
errorFields = _.uniq(@state.errorFieldNames.concat([field]))
else
errorFields = _.uniq(_.without(@state.errorFieldNames, field))
@setState({errorFieldNames: errorFields})
if providerField.type == "email" and event.target.value
if event.target.value.endsWith('@gmail.com')
# set a state that contains a "this is a gmail account" message
errorFields = _.uniq(@state.errorFieldNames.concat([field]))
@setState
errorMessage: "This looks like a Gmail account. You should go back and sign in to Gmail instead."
errorFieldNames: errorFields
@_resize()
else
@setState({errorMessage: null})
@_resize()
@setState({fields})
_onFieldKeyPress: (event) =>
if event.key in ['Enter', 'Return']
pages = @state.provider.pages || []
if pages.length > @state.pageNumber + 1
@_onNextButton()
else
@_onSubmit()
_renderTitle: =>
if @state.provider.name is 'gmail'
<h2>
Sign in to Google in<br/>your browser.
</h2>
else if @state.provider.pages?.length > 0
<h2>
{@state.provider.pages[@state.pageNumber]}
</h2>
else
<h2>
Sign in to {@state.provider.displayName}
</h2>
_renderErrorMessage: =>
return unless @state.errorMessage
text = @state.errorMessage
result = RegExpUtils.urlRegex(matchEntireString: false).exec(text)
if result
link = result[0]
beforeText = text.substr(0, result.index)
afterText = text.substr(result.index + link.length)
return (
<div className="errormsg">
{beforeText}<a href={link}>{link}</a>{afterText}
</div>
)
else
return (
<div className="errormsg">
{text}
</div>
)
_fieldOnCurrentPage: (field) =>
!@state.provider.pages || field.page is @state.pageNumber
_renderFields: =>
@state.provider.fields?.filter(@_fieldOnCurrentPage)
.map (field, idx) =>
errclass = if field.name in @state.errorFieldNames then "error " else ""
<label className={(field.className || "")} key={field.name}>
{field.label}
<input type={field.type}
tabIndex={idx + 1}
value={@state.fields[field.name]}
onChange={@_onValueChanged}
onKeyPress={@_onFieldKeyPress}
data-field={field.name}
data-format={field.format ? ""}
disabled={@state.tryingToAuthenticate}
className={errclass}
placeholder={field.placeholder} />
</label>
_renderSettings: =>
@state.provider.settings?.filter(@_fieldOnCurrentPage)
.map (field, idx) =>
if field.type is 'checkbox'
<label className={"checkbox #{field.className ? ""}"} key={field.name}>
<input type={field.type}
tabIndex={idx + 5}
checked={@state.settings[field.name]}
onChange={@_onSettingsChanged}
onKeyPress={@_onFieldKeyPress}
data-field={field.name}
disabled={@state.tryingToAuthenticate}
data-format={field.format ? ""}
className={field.className ? ""} />
{field.label}
</label>
else
errclass = if field.name in @state.errorFieldNames then "error " else ""
<label className={field.className ? ""}
style={if field.advanced and not @state.show_advanced then {display:'none'} else {}}
key={field.name}>
{field.label}
<input type={field.type}
tabIndex={idx + 5}
value={@state.settings[field.name]}
onChange={@_onSettingsChanged}
onKeyPress={@_onFieldKeyPress}
data-field={field.name}
data-format={field.format ? ""}
disabled={@state.tryingToAuthenticate}
className={errclass+(field.className ? "")}
placeholder={field.placeholder} />
</label>
_renderButton: =>
pages = @state.provider.pages || []
if pages.length > @state.pageNumber + 1
# We're not on the last page.
if @_noFormErrors() and @_allRequiredFieldsFilled()
<button className="btn btn-large btn-gradient" onClick={@_onNextButton}>Continue</button>
else
# Disable the "Continue" button if the fields haven't been filled correctly.
<button className="btn btn-large btn-gradient btn-disabled">Continue</button>
else if @state.provider.name isnt 'gmail'
if @state.tryingToAuthenticate
<button className="btn btn-large btn-disabled btn-add-account-spinning">
<RetinaImg name="sending-spinner.gif" width={15} height={15} mode={RetinaImg.Mode.ContentPreserve} /> Adding account&hellip;
</button>
else
if @_noFormErrors() and @_allRequiredFieldsFilled()
<button className="btn btn-large btn-gradient btn-add-account" onClick={@_onSubmit}>Add account</button>
else
# Disable the "Add Account" button if the fields haven't been filled correctly.
<button className="btn btn-large btn-gradient btn-add-account btn-disabled">Add account</button>
_onNextButton: (event) =>
return unless @_noFormErrors() and @_allRequiredFieldsFilled()
@setState(pageNumber: @state.pageNumber + 1)
@_resize()
_onSubmit: (event) =>
return unless @_noFormErrors() and @_allRequiredFieldsFilled()
return if @state.tryingToAuthenticate
data = settings: {}
for own k,v of @state.fields when v isnt ''
data[k] = v
for own k,v of @state.settings when v isnt ''
data.settings[k] = v
data.provider = @state.provider.name
# if there's an account with this email, get the ID for it to notify the backend of re-auth
account = AccountStore.accountForEmail(data.email)
reauthParam = if account then "&reauth=#{account.id}" else ""
# handle special case for exchange/outlook/hotmail username field
if data.provider in ['exchange','outlook','hotmail'] and not data.settings.username?.trim().length
data.settings.username = data.email
@setState(tryingToAuthenticate: true)
# Send the form data directly to Nylas to get code
# If this succeeds, send the received code to N1 server to register the account
# Otherwise process the error message from the server and highlight UI as needed
NylasAPI.makeRequest
path: "/auth?client_id=#{NylasAPI.AppID}&n1_id=#{NylasEnv.config.get('updateIdentity')}#{reauthParam}"
method: 'POST'
body: data
returnsModel: false
timeout: 60000
auth:
user: ''
pass: ''
sendImmediately: true
.then (json) =>
invite_code = NylasEnv.config.get('invitationCode')
json.invite_code = invite_code
json.email = data.email
EdgehillAPI.request
path: "/connect/nylas"
method: "POST"
timeout: 60000
body: json
success: @_onAccountReceived
error: @_onNetworkError
.catch(@_onNetworkError)
_onAccountReceived: (json) =>
Actions.recordUserEvent('Auth Successful', {
provider: @state.provider.name
})
try
OnboardingActions.accountJSONReceived(json)
catch e
NylasEnv.reportError(e)
@setState
tryingToAuthenticate: false
errorMessage: "Sorry, something went wrong on the Nylas server. Please try again. If you're still having issues, contact us at support@nylas.com."
@_resize()
_onNetworkError: (err) =>
errorMessage = err.message
Actions.recordUserEvent('Auth Failed', {
errorMessage: errorMessage
provider: @state.provider.name
})
if errorMessage is "Invite code required"
choice = dialog.showMessageBox(remote.getCurrentWindow(), {
type: 'info',
buttons: ['Okay'],
title: 'Confirm',
message: 'Due to a large number of sign-ups this week, youll need an invitation code to add another account! Visit http://invite.nylas.com/ to grab one, or hold tight!'
})
OnboardingActions.moveToPage("token-auth")
if errorMessage is "Invalid invite code"
OnboardingActions.moveToPage("token-auth")
pageNumber = @state.pageNumber
errorFieldNames = err.body?.missing_fields || err.body?.missing_settings
if err.errorTitle is "setting_update_error"
@setState
tryingToAuthenticate: false
errorMessage: 'The IMAP/SMTP servers for this account do not match our records. Please verify that any server names you entered are correct. If your IMAP/SMTP server has changed, first remove this account from N1, then try logging in again.'
@_resize()
OnboardingActions.moveToPage("account-settings")
return
if errorFieldNames
{pageNumber, errorMessage} = @_stateForMissingFieldNames(errorFieldNames)
if err.statusCode is -123 # timeout
errorMessage = "Request timed out. Please try again."
@setState
pageNumber: pageNumber
errorMessage: errorMessage
errorFieldNames: errorFieldNames || []
tryingToAuthenticate: false
@_resize()
_stateForMissingFieldNames: (fieldNames) ->
fieldLabels = []
fields = [].concat(@state.provider.settings, @state.provider.fields)
pageNumbers = [@state.pageNumber]
for fieldName in fieldNames
for s in fields when s.name is fieldName
fieldLabels.push(s.label.toLowerCase())
if s.page isnt undefined
pageNumbers.push(s.page)
pageNumber = Math.min.apply(null, pageNumbers)
errorMessage = @_messageForFieldLabels(fieldLabels)
{pageNumber, errorMessage}
_messageForFieldLabels: (labels) ->
if labels.length > 2
return "Please fix the highlighted fields."
else if labels.length is 2
return "Please provide your #{labels[0]} and #{labels[1]}."
else
return "Please provide your #{labels[0]}."
_pollForGmailAccount: (callback) =>
EdgehillAPI.request
path: "/oauth/google/token?key="+@state.provider.clientKey
method: "GET"
success: (json) =>
callback(json)
error: (err) =>
callback()
_resize: =>
clearTimeout(@_resizeTimer) if @_resizeTimer
@_resizeTimer = setTimeout( =>
@props.onResize?()
, 10)
_onMoveToPrevPage: =>
if @state.pageNumber > 0
@setState(pageNumber: @state.pageNumber - 1)
@_resize()
else
OnboardingActions.moveToPreviousPage()
module.exports = AccountSettingsPage

View file

@ -1,266 +0,0 @@
RegExpUtils = require('nylas-exports').RegExpUtils
validEmail = (address) ->
return RegExpUtils.emailRegex().test(address)
validDomain = (domain) ->
return RegExpUtils.domainRegex().test(domain) || RegExpUtils.ipAddressRegex().test(domain)
Providers = [
{
name: 'gmail'
displayName: 'Gmail or Google Apps'
icon: 'ic-settings-account-gmail.png'
header_icon: 'setup-icon-provider-gmail.png'
color: '#e99999'
settings: []
}, {
name: 'exchange'
displayName: 'Microsoft Exchange'
icon: 'ic-settings-account-eas.png'
header_icon: 'setup-icon-provider-exchange.png'
color: '#1ea2a3'
pages: ['Set up your email account', 'Exchange settings']
fields: [
{
name: 'name'
type: 'text'
placeholder: 'Ashton Letterman'
label: 'Name'
required: true
page: 0
}, {
name: 'email'
type: 'email'
placeholder: 'you@example.com'
label: 'Email'
isValid: validEmail
required: true
page: 0
}
]
settings: [
{
name: 'username'
type: 'text'
placeholder: 'MYCORP\\bob (if known)'
label: 'Username (optional)'
page: 1
}, {
name: 'password'
type: 'password'
placeholder: 'Password'
label: 'Password'
required: true
page: 1
}, {
name: 'eas_server_host'
type: 'text'
placeholder: 'mail.company.com'
label: 'Exchange server (optional)'
isValid: validDomain
page: 1
}
]
}, {
name: 'icloud'
displayName: 'iCloud'
icon: 'ic-settings-account-icloud.png'
header_icon: 'setup-icon-provider-icloud.png'
color: '#61bfe9'
fields: [
{
name: 'name'
type: 'text'
placeholder: 'Ashton Letterman'
label: 'Name'
required: true
page: 0
}, {
name: 'email'
type: 'email'
placeholder: 'you@icloud.com'
label: 'Email'
isValid: validEmail
required: true
page: 0
}
]
settings: [{
name: 'password'
type: 'password'
placeholder: 'Password'
label: 'Password'
required: true
page: 0
}]
}, {
name: 'outlook'
displayName: 'Outlook.com'
icon: 'ic-settings-account-outlook.png'
header_icon: 'setup-icon-provider-outlook.png'
color: '#1174c3'
fields: [
{
name: 'name'
type: 'text'
placeholder: 'Ashton Letterman'
label: 'Name'
required: true
page: 0
}, {
name: 'email'
type: 'email'
placeholder: 'you@hotmail.com'
label: 'Email'
isValid: validEmail
required: true
page: 0
}
]
settings: [{
name: 'password'
type: 'password'
placeholder: 'Password'
label: 'Password'
required: true
page: 0
}]
}, {
name: 'yahoo'
displayName: 'Yahoo'
icon: 'ic-settings-account-yahoo.png'
header_icon: 'setup-icon-provider-yahoo.png'
color: '#a76ead'
fields: [
{
name: 'name'
type: 'text'
placeholder: 'Ashton Letterman'
label: 'Name'
required: true
page: 0
}, {
name: 'email'
type: 'email'
placeholder: 'you@yahoo.com'
label: 'Email'
isValid: validEmail
required: true
page: 0
}
]
settings: [{
name: 'password'
type: 'password'
placeholder: 'Password'
label: 'Password'
required: true
}]
}, {
name: 'imap'
displayName: 'IMAP / SMTP Setup'
icon: 'ic-settings-account-imap.png'
header_icon: 'setup-icon-provider-imap.png'
pages: ['Set up your email account','Configure incoming mail','Configure outgoing mail']
fields: [
{
name: 'name'
type: 'text'
placeholder: 'Ashton Letterman'
label: 'Name'
page: 0
required: true
}, {
name: 'email'
type: 'email'
placeholder: 'you@example.com'
label: 'Email'
isValid: validEmail
page: 0
required: true
}
]
settings: [
{
name: 'imap_host'
type: 'text'
placeholder: 'imap.domain.com'
label: 'IMAP Server'
page: 1
required: true
isValid: validDomain
}, {
name: 'imap_port'
type: 'text'
placeholder: '993'
label: 'Port (optional)'
className: 'half'
default: 993
format: 'integer'
page: 1
}, {
name: 'ssl_required'
type: 'checkbox'
label: 'Require SSL'
className: 'half'
default: true
page: 1
}, {
name: 'imap_username'
type: 'text'
placeholder: 'Username'
label: 'Username'
page: 1
required: true
}, {
name: 'imap_password'
type: 'password'
placeholder: 'Password'
label: 'Password'
page: 1
required: true
}, {
name: 'smtp_host'
type: 'text'
placeholder: 'smtp.domain.com'
label: 'SMTP Server'
page: 2
required: true
isValid: validDomain
}, {
name: 'smtp_port'
type: 'text'
placeholder: '587'
label: 'Port (optional)'
className: 'half'
format: 'integer'
default: 587
page: 2
}, {
name: 'ssl_required'
type: 'checkbox'
label: 'Require SSL'
className: 'half'
default: true
page: 2
}, {
name: 'smtp_username'
type: 'text'
placeholder: 'Username'
label: 'Username'
page: 2
required: true
}, {
name: 'smtp_password'
type: 'password'
placeholder: 'Password'
label: 'Password'
page: 2
required: true
}
]
}
]
module.exports = Providers

View file

@ -0,0 +1,48 @@
const AccountTypes = [
{
type: 'gmail',
displayName: 'Gmail or Google Apps',
icon: 'ic-settings-account-gmail.png',
headerIcon: 'setup-icon-provider-gmail.png',
color: '#e99999',
},
{
type: 'exchange',
displayName: 'Microsoft Exchange',
icon: 'ic-settings-account-eas.png',
headerIcon: 'setup-icon-provider-exchange.png',
color: '#1ea2a3',
},
{
type: 'icloud',
displayName: 'iCloud',
icon: 'ic-settings-account-icloud.png',
headerIcon: 'setup-icon-provider-icloud.png',
color: '#61bfe9',
},
{
type: 'outlook',
displayName: 'Outlook.com',
icon: 'ic-settings-account-outlook.png',
headerIcon: 'setup-icon-provider-outlook.png',
color: '#1174c3',
},
{
type: 'yahoo',
displayName: 'Yahoo',
icon: 'ic-settings-account-yahoo.png',
headerIcon: 'setup-icon-provider-yahoo.png',
color: '#a76ead',
},
{
type: 'imap',
displayName: 'IMAP / SMTP Setup',
title: 'Setup your account',
icon: 'ic-settings-account-imap.png',
headerIcon: 'setup-icon-provider-imap.png',
color: '#aaa',
},
]
export default AccountTypes;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,185 @@
import React from 'react';
import ReactDOM from 'react-dom';
import {RetinaImg} from 'nylas-component-kit';
import {Actions} from 'nylas-exports';
import OnboardingActions from '../onboarding-actions';
import {runAuthRequest} from '../onboarding-helpers';
import FormErrorMessage from '../form-error-message';
import AccountTypes from '../account-types'
const CreatePageForForm = (FormComponent) => {
return class Composed extends React.Component {
static displayName = FormComponent.displayName;
static propTypes = {
accountInfo: React.PropTypes.object,
};
constructor(props) {
super(props);
this.state = Object.assign({
accountInfo: JSON.parse(JSON.stringify(this.props.accountInfo)),
errorFieldNames: [],
errorMessage: null,
}, FormComponent.validateAccountInfo(this.props.accountInfo));
}
componentDidMount() {
this._applyFocus();
}
componentDidUpdate() {
this._applyFocus();
}
_applyFocus() {
const anyInputFocused = document.activeElement && document.activeElement.nodeName === 'INPUT';
if (anyInputFocused) {
return;
}
const inputs = Array.from(ReactDOM.findDOMNode(this).querySelectorAll('input'));
if (inputs.length === 0) {
return;
}
for (const input of inputs) {
if (input.value === '') {
input.focus();
return;
}
}
inputs[0].focus();
}
onFieldChange = (event) => {
const changes = {};
changes[event.target.id] = event.target.value;
const accountInfo = Object.assign({}, this.state.accountInfo, changes);
const {errorFieldNames, errorMessage, populated} = FormComponent.validateAccountInfo(accountInfo);
this.setState({accountInfo, errorFieldNames, errorMessage, populated});
}
onSubmit = () => {
OnboardingActions.setAccountInfo(this.state.accountInfo);
this.refs.form.submit();
}
onFieldKeyPress = (event) => {
if (['Enter', 'Return'].includes(event.key)) {
this.onSubmit();
}
}
onBack = () => {
OnboardingActions.setAccountInfo(this.state.accountInfo);
OnboardingActions.moveToPreviousPage();
}
onConnect = () => {
const {accountInfo} = this.state;
this.setState({submitting: true});
runAuthRequest(accountInfo)
.then((json) => {
OnboardingActions.accountJSONReceived(json)
})
.catch((err) => {
Actions.recordUserEvent('Auth Failed', {
errorMessage: err.message,
provider: accountInfo.type,
})
const errorFieldNames = err.body ? (err.body.missing_fields || err.body.missing_settings || []) : []
let errorMessage = err.message;
if (err.errorTitle === "setting_update_error") {
errorMessage = 'The IMAP/SMTP servers for this account do not match our records. Please verify that any server names you entered are correct. If your IMAP/SMTP server has changed, first remove this account from N1, then try logging in again.';
}
if (err.errorTitle.includes("autodiscover") && (accountInfo.type === 'exchange')) {
errorFieldNames.push('eas_server_host')
errorFieldNames.push('username');
}
if (err.statusCode === -123) { // timeout
errorMessage = "Request timed out. Please try again."
}
this.setState({errorMessage, errorFieldNames, submitting: false});
});
}
_renderButton() {
const {accountInfo, submitting, errorFieldNames, populated} = this.state;
const buttonLabel = FormComponent.submitLabel(accountInfo);
// We're not on the last page.
if (submitting) {
return (
<button className="btn btn-large btn-disabled btn-add-account spinning">
<RetinaImg name="sending-spinner.gif" width={15} height={15} mode={RetinaImg.Mode.ContentPreserve} />
Adding account&hellip;
</button>
);
}
if (errorFieldNames.length || !populated) {
return (
<button className="btn btn-large btn-gradient btn-disabled btn-add-account">{buttonLabel}</button>
);
}
return (
<button className="btn btn-large btn-gradient btn-add-account" onClick={this.onSubmit}>{buttonLabel}</button>
);
}
render() {
const {accountInfo, errorMessage, errorFieldNames, submitting} = this.state;
const AccountType = AccountTypes.find(a => a.type === accountInfo.type);
if (!AccountType) {
throw new Error(`Cannot find account type ${accountInfo.type}`);
}
return (
<div className={`page account-setup ${FormComponent.displayName}`}>
<div className="logo-container">
<RetinaImg
style={{backgroundColor: AccountType.color, borderRadius: 44}}
name={AccountType.headerIcon}
mode={RetinaImg.Mode.ContentPreserve}
className="logo"
/>
</div>
<h2>
{FormComponent.titleLabel(AccountType)}
</h2>
<FormErrorMessage
message={errorMessage}
empty={FormComponent.subtitleLabel(AccountType)}
/>
<FormComponent
ref="form"
accountInfo={accountInfo}
errorFieldNames={errorFieldNames}
submitting={submitting}
onFieldChange={this.onFieldChange}
onFieldKeyPress={this.onFieldKeyPress}
onConnect={this.onConnect}
/>
<div>
<div className="btn btn-large btn-gradient" onClick={this.onBack}>Back</div>
{this._renderButton()}
</div>
</div>
);
}
}
}
export default CreatePageForForm;

View file

@ -0,0 +1,34 @@
import React from 'react';
import {RegExpUtils} from 'nylas-exports';
const FormErrorMessage = (props) => {
let {message, empty} = props;
if (!message) {
return <div className="message empty">{empty}</div>;
}
const result = RegExpUtils.urlRegex({matchEntireString: false}).exec(message);
if (result) {
const link = result[0];
return (
<div className="message error">
{message.substr(0, result.index)}
<a href={link}>{link}</a>
{message.substr(result.index + link.length)}
</div>
);
}
return (
<div className="message error">
{message}
</div>
);
}
FormErrorMessage.propTypes = {
empty: React.PropTypes.string,
message: React.PropTypes.string,
};
export default FormErrorMessage;

View file

@ -0,0 +1,33 @@
import React from 'react';
const FormField = (props) => {
return (
<span>
<label forHtml={props.field}>{props.title}:</label>
<input
type={props.type || "text"}
id={props.field}
style={props.style}
className={(props.accountInfo[props.field] && props.errorFieldNames.includes(props.field)) ? 'error' : ''}
disabled={props.submitting}
value={props.accountInfo[props.field] || ''}
onKeyPress={props.onFieldKeyPress}
onChange={props.onFieldChange}
/>
</span>
);
}
FormField.propTypes = {
field: React.PropTypes.string,
title: React.PropTypes.string,
type: React.PropTypes.string,
style: React.PropTypes.object,
submitting: React.PropTypes.bool,
onFieldKeyPress: React.PropTypes.func,
onFieldChange: React.PropTypes.func,
errorFieldNames: React.PropTypes.array,
accountInfo: React.PropTypes.object,
}
export default FormField;

View file

@ -1,69 +0,0 @@
React = require 'react'
path = require 'path'
{RetinaImg, ConfigPropContainer} = require 'nylas-component-kit'
{EdgehillAPI} = require 'nylas-exports'
OnboardingActions = require './onboarding-actions'
class InstallButton extends React.Component
constructor: (@props) ->
@state =
installed: !NylasEnv.packages.isPackageDisabled(@props.package.name)
render: =>
classname = "btn btn-install"
classname += " installed" if @state.installed
<div className={classname} onClick={@_onInstall}></div>
_onInstall: =>
NylasEnv.packages.enablePackage(@props.package.name)
@setState(installed: true)
class InitialPackagesPage extends React.Component
@displayName: "InitialPackagesPage"
constructor: (@props) ->
@state =
packages: NylasEnv.packages.getAvailablePackageMetadata().filter ({isStarterPackage}) => isStarterPackage
render: =>
<div className="page opaque" style={width:900, height:650}>
<div className="back" onClick={@_onPrevPage}>
<RetinaImg name="onboarding-back.png" mode={RetinaImg.Mode.ContentPreserve}/>
</div>
<h1 style={paddingTop: 60, marginBottom: 20}>Explore plugins</h1>
<p style={paddingBottom: 20}>
Plugins lie at the heart of N1 and give it its powerful features.<br/>
Want to enable a few example plugins now?
</p>
<div>
{@state.packages.map (item) =>
<div className="initial-package" key={item.name}>
<div className="icon-container">
<img src="nylas://#{item.name}/#{item.icon}" style={width:27, alignContent: 'center', objectFit: 'scale-down'} />
</div>
<div className="install-container">
<InstallButton package={item} />
</div>
<div className="name">{item.title}</div>
<div className="description">{item.description}</div>
</div>
}
</div>
<button className="btn btn-large btn-get-started btn-emphasis"
style={marginTop: 15}
onClick={@_onGetStarted}>
Start Using N1
</button>
</div>
_onPrevPage: =>
OnboardingActions.moveToPage('initial-preferences')
_onGetStarted: =>
require('electron').ipcRenderer.send('account-setup-successful')
module.exports = InitialPackagesPage

View file

@ -1,22 +0,0 @@
PageRouter = require "./page-router"
{SystemStartService, WorkspaceStore, ComponentRegistry} = require 'nylas-exports'
module.exports =
item: null
activate: (@state) ->
# This package does nothing in other windows
return unless NylasEnv.getWindowType() is 'onboarding'
WorkspaceStore.defineSheet 'Main', {root: true},
list: ['Center']
ComponentRegistry.register PageRouter,
location: WorkspaceStore.Location.Center
if (NylasEnv.config.get('nylas.accounts')?.length ? 0) is 0
startService = new SystemStartService()
startService.checkAvailability().then (available) =>
return unless available
startService.doesLaunchOnSystemStart().then (launchesOnStart) =>
startService.configureToLaunchOnSystemStart() unless launchesOnStart

View file

@ -0,0 +1,34 @@
import {SystemStartService, WorkspaceStore, ComponentRegistry} from 'nylas-exports';
import OnboardingRoot from './onboarding-root';
export function activate() {
WorkspaceStore.defineSheet('Main', {root: true}, {list: ['Center']});
ComponentRegistry.register(OnboardingRoot, {
location: WorkspaceStore.Location.Center,
});
const accounts = NylasEnv.config.get('nylas.accounts') || [];
if (accounts.length === 0) {
const startService = new SystemStartService();
startService.checkAvailability().then((available) => {
if (!available) {
return;
}
startService.doesLaunchOnSystemStart().then((launchesOnStart) => {
if (!launchesOnStart) {
startService.configureToLaunchOnSystemStart();
}
});
});
}
}
export function deactivate() {
}
export function serialize() {
}

View file

@ -1,17 +0,0 @@
Actions = require './onboarding-actions'
NylasStore = require 'nylas-store'
class NylasApiEnvironmentStore extends NylasStore
constructor: ->
@listenTo Actions.changeAPIEnvironment, @_setEnvironment
@_setEnvironment('production') unless NylasEnv.config.get('env')
getEnvironment: ->
NylasEnv.config.get('env')
_setEnvironment: (env) ->
throw new Error("Environment #{env} is not allowed") unless env in ['development', 'experimental', 'staging', 'production']
NylasEnv.config.set('env', env)
@trigger()
module.exports = new NylasApiEnvironmentStore()

View file

@ -1,16 +0,0 @@
Reflux = require 'reflux'
OnboardingActions = Reflux.createActions [
"changeAPIEnvironment"
"loadExternalAuthPage"
"closeWindow"
"moveToPreviousPage"
"moveToPage"
"accountJSONReceived"
"retryCheckTokenAuthStatus"
]
for key, action of OnboardingActions
action.sync = true
module.exports = OnboardingActions

View file

@ -0,0 +1,16 @@
import Reflux from 'reflux';
const OnboardingActions = Reflux.createActions([
"setAccountInfo",
"setAccountType",
"moveToPreviousPage",
"moveToPage",
"authenticationJSONReceived",
"accountJSONReceived",
]);
for (const key of Object.keys(OnboardingActions)) {
OnboardingActions[key].sync = true;
}
export default OnboardingActions;

View file

@ -0,0 +1,159 @@
/* eslint global-require: 0 */
import crypto from 'crypto';
import {EdgehillAPI, NylasAPI, AccountStore, RegExpUtils} from 'nylas-exports';
import url from 'url';
function base64url(buf) {
return buf.toString('base64')
.replace(/\+/g, '-') // Convert '+' to '-'
.replace(/\//g, '_'); // Convert '/' to '_'
}
export function pollForGmailAccount(sessionKey, callback) {
EdgehillAPI.makeRequest({
path: `/oauth/google/token?key=${sessionKey}`,
method: "GET",
error: callback,
success: (json) => {
if (json && json.data) {
callback(null, JSON.parse(json.data));
} else {
callback(null, null);
}
},
});
}
export function buildGmailSessionKey() {
return base64url(crypto.randomBytes(40));
}
export function buildGmailAuthURL(sessionKey) {
// Use a different app for production and development.
const env = NylasEnv.config.get('env') || 'production';
let googleClientId = '372024217839-cdsnrrqfr4d6b4gmlqepd7v0n0l0ip9q.apps.googleusercontent.com';
if (env !== 'production') {
googleClientId = '529928329786-e5foulo1g9kiej2h9st9sb0f4dt96s6v.apps.googleusercontent.com';
}
const encryptionKey = base64url(crypto.randomBytes(24));
const encryptionIv = base64url(crypto.randomBytes(16));
return url.format({
protocol: 'https',
host: 'accounts.google.com/o/oauth2/auth',
query: {
response_type: 'code',
state: `${sessionKey},${encryptionKey},${encryptionIv},`,
client_id: googleClientId,
redirect_uri: `${EdgehillAPI.APIRoot}/oauth/google/callback`,
access_type: 'offline',
scope: `https://www.googleapis.com/auth/userinfo.email \
https://www.googleapis.com/auth/userinfo.profile \
https://mail.google.com/ \
https://www.google.com/m8/feeds \
https://www.googleapis.com/auth/calendar`,
prompt: 'consent',
},
});
}
export function buildWelcomeURL(account) {
return url.format({
protocol: 'https',
host: 'nylas.com/welcome',
query: {
n: base64url(NylasEnv.config.get("updateIdentity")),
e: base64url(account.emailAddress),
p: base64url(account.provider),
a: base64url(account.id),
},
});
}
export function runAuthRequest(accountInfo) {
const {username, type, email, name} = accountInfo;
const data = {
provider: type,
email: email,
name: name,
settings: Object.assign({}, accountInfo),
};
// handle special case for exchange/outlook/hotmail username field
data.settings.username = username || email;
if (data.settings.imap_port) {
data.settings.imap_port = data.settings.imap_port / 1;
}
if (data.settings.smtp_port) {
data.settings.smtp_port = data.settings.smtp_port / 1;
}
// if there's an account with this email, get the ID for it to notify the backend of re-auth
const account = AccountStore.accountForEmail(accountInfo.email);
const reauthParam = account ? `&reauth=${account.id}` : "";
// Send the form data directly to Nylas to get code
// If this succeeds, send the received code to N1 server to register the account
// Otherwise process the error message from the server and highlight UI as needed
return NylasAPI.makeRequest({
path: `/auth?client_id=${NylasAPI.AppID}&n1_id=${NylasEnv.config.get('updateIdentity')}${reauthParam}`,
method: 'POST',
body: data,
returnsModel: false,
timeout: 60000,
auth: {
user: '',
pass: '',
sendImmediately: true,
},
})
.then((json) => {
json.email = data.email;
return EdgehillAPI.makeRequest({
path: "/connect/nylas",
method: "POST",
timeout: 60000,
body: json,
})
})
}
export function isValidHost(value) {
return RegExpUtils.domainRegex().test(value) || RegExpUtils.ipAddressRegex().test(value);
}
export function accountInfoWithIMAPAutocompletions(existingAccountInfo) {
const CommonProviderSettings = require('./common-provider-settings.json');
const email = existingAccountInfo.email;
const domain = email.split('@').pop().toLowerCase();
const template = CommonProviderSettings[domain] || {};
const usernameWithFormat = (format) => {
if (format === 'email') {
return email
}
if (format === 'email-without-domain') {
return email.split('@').shift();
}
return undefined;
}
// always pre-fill SMTP / IMAP username, password and port.
const defaults = {
imap_host: template.imap_host,
imap_port: template.imap_port || 993,
imap_username: usernameWithFormat(template.imap_user_format),
imap_password: existingAccountInfo.password,
smtp_host: template.smtp_host,
smtp_port: template.smtp_port || 587,
smtp_username: usernameWithFormat(template.smtp_user_format),
smtp_password: existingAccountInfo.password,
ssl_required: (template.ssl === '1'),
}
return Object.assign({}, existingAccountInfo, defaults);
}

View file

@ -0,0 +1,82 @@
import React from 'react';
import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
import OnboardingStore from './onboarding-store';
import PageTopBar from './page-top-bar';
import WelcomePage from './page-welcome';
import TutorialPage from './page-tutorial';
import AuthenticatePage from './page-authenticate';
import AccountChoosePage from './page-account-choose';
import AccountSettingsPage from './page-account-settings';
import AccountSettingsPageGmail from './page-account-settings-gmail';
import AccountSettingsPageIMAP from './page-account-settings-imap';
import AccountSettingsPageExchange from './page-account-settings-exchange';
import InitialPreferencesPage from './page-initial-preferences';
const PageComponents = {
"welcome": WelcomePage,
"tutorial": TutorialPage,
"authenticate": AuthenticatePage,
"account-choose": AccountChoosePage,
"account-settings": AccountSettingsPage,
"account-settings-gmail": AccountSettingsPageGmail,
"account-settings-imap": AccountSettingsPageIMAP,
"account-settings-exchange": AccountSettingsPageExchange,
"initial-preferences": InitialPreferencesPage,
}
export default class OnboardingRoot extends React.Component {
static displayName = 'OnboardingRoot';
static containerRequired = false;
constructor(props) {
super(props);
this.state = this._getStateFromStore();
}
componentDidMount() {
this.unsubscribe = OnboardingStore.listen(this._onStateChanged, this);
NylasEnv.center();
NylasEnv.displayWindow();
}
componentWillUnmount() {
if (this.unsubscribe) {
this.unsubscribe();
}
}
_getStateFromStore = () => {
return {
page: OnboardingStore.page(),
pageDepth: OnboardingStore.pageDepth(),
accountInfo: OnboardingStore.accountInfo(),
};
}
_onStateChanged = () => {
this.setState(this._getStateFromStore());
}
render() {
const Component = PageComponents[this.state.page];
if (!Component) {
throw new Error(`Cannot find component for page: ${this.state.page}`);
}
return (
<div className="page-frame">
<PageTopBar pageDepth={this.state.pageDepth} />
<ReactCSSTransitionGroup
transitionName="alpha-fade"
transitionLeaveTimeout={150}
transitionEnterTimeout={150}
>
<div key={this.state.page} className="page-container">
<Component accountInfo={this.state.accountInfo} ref="activePage" />
</div>
</ReactCSSTransitionGroup>
</div>
);
}
}

View file

@ -0,0 +1,156 @@
import OnboardingActions from './onboarding-actions';
import {AccountStore, Actions, IdentityStore} from 'nylas-exports';
import {shell, ipcRenderer} from 'electron';
import NylasStore from 'nylas-store';
import {buildWelcomeURL} from './onboarding-helpers';
function accountTypeForProvider(provider) {
if (provider === 'eas') {
return 'exchange';
}
if (provider === 'custom') {
return 'imap';
}
return provider;
}
class OnboardingStore extends NylasStore {
constructor() {
super();
this.listenTo(OnboardingActions.moveToPreviousPage, this._onMoveToPreviousPage)
this.listenTo(OnboardingActions.moveToPage, this._onMoveToPage)
this.listenTo(OnboardingActions.accountJSONReceived, this._onAccountJSONReceived)
this.listenTo(OnboardingActions.authenticationJSONReceived, this._onAuthenticationJSONReceived)
this.listenTo(OnboardingActions.setAccountInfo, this._onSetAccountInfo);
this.listenTo(OnboardingActions.setAccountType, this._onSetAccountType);
const {existingAccount, addingAccount} = NylasEnv.getWindowProps();
const identity = IdentityStore.identity();
if (identity) {
this._accountInfo = {
name: `${identity.firstname || ""} ${identity.lastname || ""}`,
};
} else {
this._accountInfo = {};
}
if (existingAccount) {
const accountType = accountTypeForProvider(existingAccount.provider);
this._pageStack = ['account-choose']
this._accountInfo = {
name: existingAccount.name,
email: existingAccount.emailAddress,
};
this._onSetAccountType(accountType);
} else if (addingAccount) {
this._pageStack = ['account-choose'];
} else {
this._pageStack = ['welcome'];
}
}
_onOnboardingComplete = () => {
// When account JSON is received, we want to notify external services
// that it succeeded. Unfortunately in this case we're likely to
// close the window before those requests can be made. We add a short
// delay here to ensure that any pending requests have a chance to
// clear before the window closes.
setTimeout(() => {
ipcRenderer.send('account-setup-successful');
}, 100);
}
_onSetAccountType = (type) => {
let nextPage = "account-settings";
if (type === 'gmail') {
nextPage = "account-settings-gmail";
} else if (type === 'exchange') {
nextPage = "account-settings-exchange";
}
Actions.recordUserEvent('Auth Flow Started', {type});
this._onSetAccountInfo(Object.assign({}, this._accountInfo, {type}));
this._onMoveToPage(nextPage);
}
_onSetAccountInfo = (info) => {
this._accountInfo = info;
this.trigger();
}
_onMoveToPreviousPage = () => {
this._pageStack.pop();
this.trigger();
}
_onMoveToPage = (page) => {
this._pageStack.push(page)
this.trigger();
}
_onAuthenticationJSONReceived = (json) => {
const isFirstAccount = AccountStore.accounts().length === 0;
Actions.setNylasIdentity(json);
setTimeout(() => {
if (isFirstAccount) {
this._onSetAccountInfo(Object.assign({}, this._accountInfo, {
name: `${json.firstname || ""} ${json.lastname || ""}`,
email: json.email,
}));
OnboardingActions.moveToPage('account-choose');
} else {
this._onOnboardingComplete();
}
}, 1000);
}
_onAccountJSONReceived = (json) => {
try {
const isFirstAccount = AccountStore.accounts().length === 0;
AccountStore.addAccountFromJSON(json);
this._accountFromAuth = AccountStore.accountForEmail(json.email_address);
Actions.recordUserEvent('Auth Successful', {
provider: this._accountFromAuth.provider,
});
ipcRenderer.send('new-account-added');
NylasEnv.displayWindow();
if (isFirstAccount) {
this._onMoveToPage('initial-preferences');
Actions.recordUserEvent('First Account Linked');
// open the external welcome page
const url = buildWelcomeURL(this._accountFromAuth);
shell.openExternal(url, {activate: false});
} else {
this._onOnboardingComplete();
}
} catch (e) {
NylasEnv.reportError(e);
NylasEnv.showErrorDialog("Unable to Connect Account", "Sorry, something went wrong on the Nylas server. Please try again. If you're still having issues, contact us at support@nylas.com.");
}
}
page() {
return this._pageStack[this._pageStack.length - 1];
}
pageDepth() {
return this._pageStack.length;
}
accountInfo() {
return this._accountInfo;
}
accountFromAuth() {
return this._accountFromAuth;
}
}
export default new OnboardingStore();

View file

@ -0,0 +1,44 @@
import React from 'react';
import {RetinaImg} from 'nylas-component-kit';
import OnboardingActions from './onboarding-actions';
import AccountTypes from './account-types';
export default class AccountChoosePage extends React.Component {
static displayName = "AccountChoosePage";
static propTypes = {
accountInfo: React.PropTypes.object,
}
_renderAccountTypes() {
return AccountTypes.map((accountType) =>
<div
key={accountType.type}
className={`provider ${accountType.type}`}
onClick={() => OnboardingActions.setAccountType(accountType.type)}
>
<div className="icon-container">
<RetinaImg
name={accountType.icon}
mode={RetinaImg.Mode.ContentPreserve}
className="icon"
/>
</div>
<span className="provider-name">{accountType.displayName}</span>
</div>
);
}
render() {
return (
<div className="page account-choose">
<h2>
Connect an email account
</h2>
<div className="provider-list">
{this._renderAccountTypes()}
</div>
</div>
);
}
}

View file

@ -0,0 +1,103 @@
import React from 'react';
import {RegExpUtils} from 'nylas-exports';
import {isValidHost} from './onboarding-helpers';
import CreatePageForForm from './decorators/create-page-for-form';
import FormField from './form-field';
class AccountExchangeSettingsForm extends React.Component {
static displayName = 'AccountExchangeSettingsForm';
static propTypes = {
accountInfo: React.PropTypes.object,
errorFieldNames: React.PropTypes.array,
submitting: React.PropTypes.bool,
onConnect: React.PropTypes.func,
onFieldChange: React.PropTypes.func,
onFieldKeyPress: React.PropTypes.func,
};
static submitLabel = () => {
return 'Connect Account';
}
static titleLabel = () => {
return 'Add your Exchange account';
}
static subtitleLabel = () => {
return 'Enter your Exchange credentials to get started.';
}
static validateAccountInfo = (accountInfo) => {
const {email, password, name} = accountInfo;
const errorFieldNames = [];
let errorMessage = null;
if (!email || !password || !name) {
return {errorMessage, errorFieldNames, populated: false};
}
if (!RegExpUtils.emailRegex().test(accountInfo.email)) {
errorFieldNames.push('email')
errorMessage = "Please provide a valid email address."
}
if (!accountInfo.password) {
errorFieldNames.push('password')
errorMessage = "Please provide a password for your account."
}
if (!accountInfo.name) {
errorFieldNames.push('name')
errorMessage = "Please provide your name."
}
if (accountInfo.eas_server_host && !isValidHost(accountInfo.eas_server_host)) {
errorFieldNames.push('eas_server_host')
errorMessage = "Please provide a valid host name."
}
return {errorMessage, errorFieldNames, populated: true};
}
constructor(props) {
super(props);
this.state = {showAdvanced: false};
}
submit() {
this.props.onConnect();
}
render() {
const {errorFieldNames, accountInfo} = this.props;
const showAdvanced = (
this.state.showAdvanced ||
errorFieldNames.includes('eas_server_host') ||
errorFieldNames.includes('username') ||
accountInfo.eas_server_host ||
accountInfo.username
);
let classnames = "twocol";
if (!showAdvanced) {
classnames += " hide-second-column";
}
return (
<div className={classnames}>
<div className="col">
<FormField field="name" title="Name" {...this.props} />
<FormField field="email" title="Email" {...this.props} />
<FormField field="password" title="Password" type="password" {...this.props} />
<a className="toggle-advanced" onClick={() => this.setState({showAdvanced: !this.state.showAdvanced})}>
{showAdvanced ? "Hide Advanced Options" : "Show Advanced Options"}
</a>
</div>
<div className="col">
<FormField field="username" title="Username (Optional)" {...this.props} />
<FormField field="eas_server_host" title="Exchange Server (Optional)" {...this.props} />
</div>
</div>
)
}
}
export default CreatePageForForm(AccountExchangeSettingsForm);

View file

@ -0,0 +1,86 @@
import React from 'react';
import {ipcRenderer, shell} from 'electron';
import {RetinaImg} from 'nylas-component-kit';
import {
pollForGmailAccount,
buildGmailSessionKey,
buildGmailAuthURL,
} from './onboarding-helpers';
import OnboardingActions from './onboarding-actions';
import AccountTypes from './account-types';
export default class AccountSettingsPageGmail extends React.Component {
static displayName = "AccountSettingsPageGmail";
static propTypes = {
accountInfo: React.PropTypes.object,
};
componentDidMount() {
// Show the "Sign in to Gmail" prompt for a moment before actually bouncing
// to Gmail. (400msec animation + 200msec to read)
this._sessionKey = buildGmailSessionKey();
this._pollTimer = null;
this._startTimer = setTimeout(() => {
shell.openExternal(buildGmailAuthURL(this._sessionKey));
this.startPollingForResponse();
}, 600);
}
componentWillUnmount() {
if (this._startTimer) { clearTimeout(this._startTimer); }
if (this._pollTimer) { clearTimeout(this._pollTimer); }
}
startPollingForResponse() {
let delay = 1000;
let onWindowFocused = null;
let poll = null;
onWindowFocused = () => {
delay = 1000;
if (this._pollTimer) {
clearTimeout(this._pollTimer);
this._pollTimer = setTimeout(poll, delay);
}
};
poll = () => {
pollForGmailAccount(this._sessionKey, (err, account) => {
clearTimeout(this._pollTimer);
if (account) {
ipcRenderer.removeListener('browser-window-focus', onWindowFocused);
OnboardingActions.accountJSONReceived(account);
} else {
delay = Math.min(delay * 1.2, 10000);
this._pollTimer = setTimeout(poll, delay);
}
});
}
ipcRenderer.on('browser-window-focus', onWindowFocused);
this._pollTimer = setTimeout(poll, 5000);
}
render() {
const {accountInfo} = this.props;
const iconName = AccountTypes.find(a => a.type === accountInfo.type).headerIcon;
return (
<div className="page account-setup gmail">
<div className="logo-container">
<RetinaImg
name={iconName}
mode={RetinaImg.Mode.ContentPreserve}
className="logo"
/>
</div>
<h2>
Sign in to Google in<br />your browser.
</h2>
</div>
);
}
}

View file

@ -0,0 +1,100 @@
import React from 'react';
import {isValidHost} from './onboarding-helpers';
import CreatePageForForm from './decorators/create-page-for-form';
import FormField from './form-field';
class AccountIMAPSettingsForm extends React.Component {
static displayName = 'AccountIMAPSettingsForm';
static propTypes = {
accountInfo: React.PropTypes.object,
errorFieldNames: React.PropTypes.array,
submitting: React.PropTypes.bool,
onConnect: React.PropTypes.func,
onFieldChange: React.PropTypes.func,
onFieldKeyPress: React.PropTypes.func,
};
static submitLabel = () => {
return 'Connect Account';
}
static titleLabel = () => {
return 'Setup your account';
}
static subtitleLabel = () => {
return 'Complete the IMAP and SMTP settings below to connect your account.';
}
static validateAccountInfo = (accountInfo) => {
let errorMessage = null;
const errorFieldNames = [];
for (const type of ['imap', 'smtp']) {
if (!accountInfo[`${type}_host`] || !accountInfo[`${type}_username`] || !accountInfo[`${type}_password`]) {
return {errorMessage, errorFieldNames, populated: false};
}
if (!isValidHost(accountInfo[`${type}_host`])) {
errorMessage = "Please provide a valid hostname or IP adddress.";
errorFieldNames.push(`${type}_host`);
}
if (accountInfo[`${type}_host`] === 'imap.gmail.com') {
errorMessage = "Please link Gmail accounts by choosing 'Google' on the account type screen.";
errorFieldNames.push(`${type}_host`);
}
if (!Number.isInteger(accountInfo[`${type}_port`] / 1)) {
errorMessage = "Please provide a valid port number.";
errorFieldNames.push(`${type}_port`);
}
}
return {errorMessage, errorFieldNames, populated: true};
}
submit() {
this.props.onConnect();
}
renderFieldsForType(type) {
const {accountInfo, errorFieldNames, submitting, onFieldKeyPress, onFieldChange} = this.props;
return (
<div>
<FormField field={`${type}_host`} title={"Server"} {...this.props} />
<div style={{textAlign: 'left'}}>
<FormField field={`${type}_port`} title={"Port"} style={{width: 100, marginRight: 20}} {...this.props} />
<input
type="checkbox"
id={`ssl_required`}
className={(accountInfo.imap_host && errorFieldNames.includes(`ssl_required`)) ? 'error' : ''}
disabled={submitting}
checked={accountInfo[`ssl_required`] || false}
onKeyPress={onFieldKeyPress}
onChange={onFieldChange}
/>
<label forHtml={`ssl_required`} className="checkbox">Require SSL</label>
</div>
<FormField field={`${type}_username`} title={"Username"} {...this.props} />
<FormField field={`${type}_password`} title={"Password"} type="password" {...this.props} />
</div>
);
}
render() {
return (
<div className="twocol">
<div className="col">
<div className="col-heading">Incoming Mail (IMAP):</div>
{this.renderFieldsForType('imap')}
</div>
<div className="col">
<div className="col-heading">Outgoing Mail (SMTP):</div>
{this.renderFieldsForType('smtp')}
</div>
</div>
)
}
}
export default CreatePageForForm(AccountIMAPSettingsForm);

View file

@ -0,0 +1,79 @@
import React from 'react';
import {RegExpUtils} from 'nylas-exports';
import OnboardingActions from './onboarding-actions';
import CreatePageForForm from './decorators/create-page-for-form';
import {accountInfoWithIMAPAutocompletions} from './onboarding-helpers';
import FormField from './form-field';
class AccountBasicSettingsForm extends React.Component {
static displayName = 'AccountBasicSettingsForm';
static propTypes = {
accountInfo: React.PropTypes.object,
errorFieldNames: React.PropTypes.array,
submitting: React.PropTypes.bool,
onConnect: React.PropTypes.func,
onFieldChange: React.PropTypes.func,
onFieldKeyPress: React.PropTypes.func,
};
static submitLabel = (accountInfo) => {
return (accountInfo.type === 'imap') ? 'Continue' : 'Connect Account';
}
static titleLabel = (AccountType) => {
return AccountType.title || `Add your ${AccountType.displayName} account`;
}
static subtitleLabel = () => {
return 'Enter your email account credentials to get started.';
}
static validateAccountInfo = (accountInfo) => {
const {email, password, name} = accountInfo;
const errorFieldNames = [];
let errorMessage = null;
if (!email || !password || !name) {
return {errorMessage, errorFieldNames, populated: false};
}
if (!RegExpUtils.emailRegex().test(accountInfo.email)) {
errorFieldNames.push('email')
errorMessage = "Please provide a valid email address."
}
if (!accountInfo.password) {
errorFieldNames.push('password')
errorMessage = "Please provide a password for your account."
}
if (!accountInfo.name) {
errorFieldNames.push('name')
errorMessage = "Please provide your name."
}
return {errorMessage, errorFieldNames, populated: true};
}
submit() {
if (this.props.accountInfo.type === 'imap') {
const accountInfo = accountInfoWithIMAPAutocompletions(this.props.accountInfo);
OnboardingActions.setAccountInfo(accountInfo);
OnboardingActions.moveToPage('account-settings-imap');
} else {
this.props.onConnect();
}
}
render() {
return (
<form className="settings">
<FormField field="name" title="Name" {...this.props} />
<FormField field="email" title="Email" {...this.props} />
<FormField field="password" title="Password" type="password" {...this.props} />
</form>
)
}
}
export default CreatePageForForm(AccountBasicSettingsForm);

View file

@ -0,0 +1,152 @@
import React from 'react';
import classnames from 'classnames';
import ReactDOM from 'react-dom';
import {IdentityStore} from 'nylas-exports';
import {RetinaImg} from 'nylas-component-kit';
import OnboardingActions from './onboarding-actions';
import networkErrors from 'chromium-net-errors';
class InitialLoadingCover extends React.Component {
static propTypes = {
ready: React.PropTypes.bool,
error: React.PropTypes.string,
onTryAgain: React.PropTypes.func,
}
constructor(props) {
super(props);
this.state = {};
}
componentDidMount() {
this._slowTimeout = setTimeout(() => {
this.setState({slow: true});
}, 2500);
}
componentWillUnmount() {
clearTimeout(this._slowTimeout);
this._slowTimeout = null;
}
render() {
const classes = classnames({
'webview-cover': true,
'ready': this.props.ready,
'error': this.props.error,
'slow': this.state.slow,
});
let message = this.props.error;
if (this.props.error) {
message = this.props.error;
} else if (this.state.slow) {
message = "Still trying to reach Nylas…";
} else {
message = '&nbsp;'
}
return (
<div className={classes}>
<div style={{flex: 1}} />
<RetinaImg
className="spinner"
style={{width: 20, height: 20}}
name="inline-loading-spinner.gif"
mode={RetinaImg.Mode.ContentPreserve}
/>
<div className="message">{message}</div>
<div className="btn try-again" onClick={this.props.onTryAgain}>Try Again</div>
<div style={{flex: 1}} />
</div>
);
}
}
export default class AuthenticatePage extends React.Component {
static displayName = "AuthenticatePage";
static propTypes = {
accountInfo: React.PropTypes.object,
};
constructor(props) {
super(props);
this.state = {
ready: false,
error: null,
};
}
componentDidMount() {
const webview = ReactDOM.findDOMNode(this.refs.webview);
webview.src = `${IdentityStore.URLRoot}/onboarding`;
webview.addEventListener('did-start-loading', this.webviewDidStartLoading);
webview.addEventListener('did-fail-load', this.webviewDidFailLoad);
webview.addEventListener('did-finish-load', this.webviewDidFinishLoad);
webview.addEventListener('console-message', (e) => {
console.log('Guest page logged a message:', e.message);
});
}
onTryAgain = () => {
const webview = ReactDOM.findDOMNode(this.refs.webview);
webview.reload();
}
webviewDidStartLoading = () => {
this.setState({error: null, webviewLoading: true});
}
webviewDidFailLoad = ({errorCode, errorDescription, validatedURL}) => {
// "Operation was aborted" can be fired when we move between pages quickly.
if (errorCode === -3) {
return;
}
let error = errorDescription;
if (!error) {
const e = networkErrors.createByCode(errorCode);
error = `Could not reach ${validatedURL}. ${e ? e.message : errorCode}`;
}
this.setState({ready: false, error: error, webviewLoading: false});
}
webviewDidFinishLoad = () => {
// this is sometimes called right after did-fail-load
if (this.state.error) { return; }
const js = `
var a = document.querySelector('#pro-account');
result = a ? a.innerText : null;
`;
const webview = ReactDOM.findDOMNode(this.refs.webview);
webview.executeJavaScript(js, false, (result) => {
this.setState({ready: true, webviewLoading: false});
if (result !== null) {
OnboardingActions.authenticationJSONReceived(JSON.parse(result));
}
});
}
render() {
return (
<div className="page authenticate">
<webview ref="webview"></webview>
<div className={`webview-loading-spinner loading-${this.state.webviewLoading}`}>
<RetinaImg
style={{width: 20, height: 20}}
name="inline-loading-spinner.gif"
mode={RetinaImg.Mode.ContentPreserve}
/>
</div>
<InitialLoadingCover
ready={this.state.ready}
error={this.state.error}
onTryAgain={this.onTryAgain}
/>
</div>
);
}
}

View file

@ -4,7 +4,7 @@ fs = require 'fs'
_ = require 'underscore'
{RetinaImg, Flexbox, ConfigPropContainer, NewsletterSignup} = require 'nylas-component-kit'
{EdgehillAPI, AccountStore} = require 'nylas-exports'
OnboardingActions = require './onboarding-actions'
OnboardingActions = require('./onboarding-actions').default
# NOTE: Temporarily copied from preferences module
class AppearanceModeOption extends React.Component
@ -110,19 +110,29 @@ class InitialPreferencesOptions extends React.Component
class InitialPreferencesPage extends React.Component
@displayName: "InitialPreferencesPage"
render: =>
account = AccountStore.accounts()[0]
constructor:(@props) ->
@state = {account: AccountStore.accounts()[0]}
componentDidMount: =>
@_unlisten = AccountStore.listen(@_onAccountStoreChange)
componentWillUnmount: =>
@_unlisten?()
_onAccountStoreChange: =>
@setState(account: AccountStore.accounts()[0])
render: =>
<div className="page opaque" style={width:900, height:620}>
<h1 style={paddingTop: 100}>Welcome to N1</h1>
<h4 style={marginBottom: 70}>Let's set things up to your liking.</h4>
<ConfigPropContainer>
<InitialPreferencesOptions account={account} />
<InitialPreferencesOptions account={@state.account} />
</ConfigPropContainer>
<button className="btn btn-large" style={marginBottom:60} onClick={@_onNextPage}>Looks Good!</button>
</div>
_onNextPage: =>
OnboardingActions.moveToPage("initial-packages")
require('electron').ipcRenderer.send('account-setup-successful')
module.exports = InitialPreferencesPage

View file

@ -1,112 +0,0 @@
OnboardingActions = require './onboarding-actions'
TokenAuthAPI = require './token-auth-api'
{AccountStore, Actions} = require 'nylas-exports'
{ipcRenderer} = require 'electron'
NylasStore = require 'nylas-store'
return unless NylasEnv.getWindowType() is "onboarding"
class PageRouterStore extends NylasStore
constructor: ->
NylasEnv.onWindowPropsReceived @_onWindowPropsChanged
@_page = NylasEnv.getWindowProps().page ? ''
@_pageData = NylasEnv.getWindowProps().pageData ? {}
@_pageStack = [{page: @_page, pageData: @_pageData}]
@_checkTokenAuthStatus()
@listenTo OnboardingActions.moveToPreviousPage, @_onMoveToPreviousPage
@listenTo OnboardingActions.moveToPage, @_onMoveToPage
@listenTo OnboardingActions.closeWindow, @_onCloseWindow
@listenTo OnboardingActions.accountJSONReceived, @_onAccountJSONReceived
@listenTo OnboardingActions.retryCheckTokenAuthStatus, @_checkTokenAuthStatus
_onAccountJSONReceived: (json) =>
isFirstAccount = AccountStore.accounts().length is 0
AccountStore.addAccountFromJSON(json)
ipcRenderer.send('new-account-added')
NylasEnv.displayWindow()
if isFirstAccount
@_onMoveToPage('initial-preferences', {account: json})
Actions.recordUserEvent('First Account Linked')
@openWelcomePage()
else
# When account JSON is received, we want to notify external services
# that it succeeded. Unfortunately in this case we're likely to
# close the window before those requests can be made. We add a short
# delay here to ensure that any pending requests have a chance to
# clear before the window closes.
setTimeout ->
ipcRenderer.send('account-setup-successful')
, 100
_onWindowPropsChanged: ({page, pageData}={}) =>
@_onMoveToPage(page, pageData)
openWelcomePage: ->
encode = (str) -> encodeURIComponent(new Buffer(str || "").toString('base64'))
account = AccountStore.accounts()[0]
n1_id = encode(NylasEnv.config.get("updateIdentity"))
email = encode(account.emailAddress)
provider = encode(account.provider)
accountId = encode(account.id)
params = "?n=#{n1_id}&e=#{email}&p=#{provider}&a=#{accountId}"
{shell} = require('electron')
shell.openExternal("https://nylas.com/welcome#{params}", activate: false)
page: -> @_page
pageData: -> @_pageData
tokenAuthEnabled: -> @_tokenAuthEnabled
tokenAuthEnabledError: -> @_tokenAuthEnabledError
connectType: ->
@_connectType
_onMoveToPreviousPage: ->
current = @_pageStack.pop()
prev = @_pageStack.pop()
@_onMoveToPage(prev.page, prev.pageData)
_onMoveToPage: (page, pageData={}) ->
@_pageStack.push({page, pageData})
@_page = page
@_pageData = pageData
@trigger()
_onCloseWindow: ->
isFirstAccount = AccountStore.accounts().length is 0
if isFirstAccount
NylasEnv.quit()
else
NylasEnv.close()
_checkTokenAuthStatus: ->
@_tokenAuthEnabled = "unknown"
@_tokenAuthEnabledError = null
@trigger()
TokenAuthAPI.request
path: "/status/"
returnsModel: false
timeout: 10000
success: (json) =>
if json.restricted
@_tokenAuthEnabled = "yes"
else
@_tokenAuthEnabled = "no"
if @_tokenAuthEnabled is "no" and @_page is 'token-auth'
@_onMoveToPage("account-choose")
else
@trigger()
error: (err) =>
if err.statusCode is 404
err.message = "Sorry, we could not reach the Nylas API. Please try again."
@_tokenAuthEnabledError = err.message
@trigger()
module.exports = new PageRouterStore()

View file

@ -1,101 +0,0 @@
React = require 'react'
ReactDOM = require 'react-dom'
ReactCSSTransitionGroup = require 'react-addons-css-transition-group'
OnboardingActions = require './onboarding-actions'
PageRouterStore = require './page-router-store'
WelcomePage = require './welcome-page'
AccountChoosePage = require './account-choose-page'
AccountSettingsPage = require './account-settings-page'
InitialPreferencesPage = require './initial-preferences-page'
InitialPackagesPage = require './initial-packages-page'
TokenAuthPage = require './token-auth-page'
class PageRouter extends React.Component
@displayName: 'PageRouter'
@containerRequired: false
constructor: (@props) ->
@state = @_getStateFromStore()
window.OnboardingActions = OnboardingActions
_getStateFromStore: =>
page: PageRouterStore.page()
pageData: PageRouterStore.pageData()
componentDidMount: =>
@unsubscribe = PageRouterStore.listen(@_onStateChanged, @)
setTimeout(@_initializeWindowSize, 10)
componentDidUpdate: =>
setTimeout(@_updateWindowSize, 10)
_initializeWindowSize: =>
return if @_unmounted
{width, height} = ReactDOM.findDOMNode(@refs.activePage).getBoundingClientRect()
NylasEnv.setSize(width, height)
NylasEnv.center()
NylasEnv.displayWindow()
_updateWindowSize: =>
return if @_unmounted
{width, height} = ReactDOM.findDOMNode(@refs.activePage).getBoundingClientRect()
NylasEnv.setSizeAnimated(width, height)
_onStateChanged: =>
@setState(@_getStateFromStore())
componentWillUnmount: =>
@_unmounted = true
@unsubscribe?()
render: =>
<div className="page-frame">
{@_renderDragRegion()}
<ReactCSSTransitionGroup
transitionName="alpha-fade"
transitionLeaveTimeout={150}
transitionEnterTimeout={150}>
{@_renderCurrentPage()}
{@_renderCurrentPageGradient()}
</ReactCSSTransitionGroup>
<div className="page-background" style={background: "#f6f7f8"}/>
</div>
_renderCurrentPageGradient: =>
gradient = @state.pageData?.provider?.color
if gradient
background = "linear-gradient(to top, #f6f7f8, #{gradient})"
height = 200
else
background = "linear-gradient(to top, #f6f7f8 0%, rgba(255,255,255,0) 100%), linear-gradient(to right, #E7EBAE 0%, #C1DFBC 50%, #AED7D7 100%)"
height = 330
<div className="page-gradient" key={"#{@state.page}-gradient"} style={background: background, height: height}/>
_renderCurrentPage: =>
Component = {
"welcome": WelcomePage
"token-auth": TokenAuthPage
"account-choose": AccountChoosePage
"account-settings": AccountSettingsPage
"initial-preferences": InitialPreferencesPage
"initial-packages": InitialPackagesPage
}[@state.page]
<div key={@state.page} className="page-container">
<Component pageData={@state.pageData} ref="activePage" onResize={@_updateWindowSize}/>
</div>
_renderDragRegion: ->
styles =
top:0
left: 26
right:0
height: 27
zIndex:100
position: 'absolute'
"WebkitAppRegion": "drag"
<div className="dragRegion" style={styles}></div>
module.exports = PageRouter

View file

@ -0,0 +1,50 @@
import React from 'react';
import {AccountStore} from 'nylas-exports';
import {RetinaImg} from 'nylas-component-kit';
import OnboardingActions from './onboarding-actions';
const PageTopBar = (props) => {
const {pageDepth} = props;
const closeClass = (pageDepth > 1) ? 'back' : 'close';
const closeIcon = (pageDepth > 1) ? 'onboarding-back.png' : 'onboarding-close.png';
const closeAction = () => {
const webview = document.querySelector('webview');
if (webview && webview.canGoBack()) {
webview.goBack();
} else if (pageDepth > 1) {
OnboardingActions.moveToPreviousPage();
} else {
if (AccountStore.accounts().length === 0) {
NylasEnv.quit();
} else {
NylasEnv.close();
}
}
}
return (
<div
className="dragRegion"
style={{
top: 0,
left: 26,
right: 0,
height: 27,
zIndex: 100,
position: 'absolute',
WebkitAppRegion: "drag",
}}
>
<div className={closeClass} onClick={closeAction}>
<RetinaImg name={closeIcon} mode={RetinaImg.Mode.ContentPreserve} />
</div>
</div>
)
}
PageTopBar.propTypes = {
pageDepth: React.PropTypes.number,
};
export default PageTopBar;

View file

@ -0,0 +1,137 @@
import React from 'react';
import OnboardingActions from './onboarding-actions';
const Steps = [
{
seen: false,
id: 'schedule',
title: 'Time is everything',
image: 'feature-people@2x.png',
description: 'Snooze emails to any time that suits you. Schedule emails to be sent later. With Nylas Pro, you are in th control of email spacetime.',
x: 80,
y: 4.9,
},
{
seen: false,
id: 'read-receipts',
title: 'Time is everything',
image: 'feature-snooze@2x.png',
description: 'Snooze emails to any time that suits you. Sechedule emails to be sent later. With Nylas Pro, you are in th control of email spacetime.',
x: 91,
y: 4.9,
},
{
seen: false,
id: 'activity',
title: 'Track Activity',
image: 'feature-activity@2x.png',
description: 'Snooze emails to any time that suits you. Schedule emails to be sent later. With Nylas Pro, you are in th control of email spacetime.',
x: 12.9,
y: 17,
},
{
seen: false,
id: 'mail-merge',
title: 'Composer Power',
image: 'feature-composer@2x.png',
description: 'Snooze emails to any time that suits you. Sechedule emails to be sent later. With Nylas Pro, you are in th control of email spacetime.',
x: 57,
y: 82,
},
];
export default class TutorialPage extends React.Component {
static displayName = "TutorialPage";
constructor(props) {
super(props);
this.state = {
appeared: false,
seen: [],
current: Steps[0],
}
}
componentDidMount() {
this._timer = setTimeout(() => {
this.setState({appeared: true})
}, 200);
}
componentWillUnmount() {
clearTimeout(this._timer);
}
_onBack = () => {
const nextItem = this.state.seen.pop();
if (!nextItem) {
OnboardingActions.moveToPreviousPage();
} else {
this.setState({current: nextItem});
}
}
_onNextUnseen = () => {
const nextSeen = [].concat(this.state.seen, [this.state.current]);
const nextItem = Steps.find(s => !nextSeen.includes(s));
if (nextItem) {
this.setState({current: nextItem, seen: nextSeen});
} else {
OnboardingActions.moveToPage('authenticate');
}
}
_onMouseOverOverlay = (event) => {
const item = Steps.find(i => i.id === event.target.id);
if (item) {
if (!this.state.seen.includes(item)) {
this.state.seen.push(item);
}
this.setState({current: item});
}
}
render() {
const {current, seen, appeared} = this.state;
return (
<div className={`page tutorial appeared-${appeared}`}>
<div className="tutorial-container">
<div className="left">
<div className="screenshot">
{Steps.map((step) =>
<div
key={step.id}
id={step.id}
className={`overlay ${seen.includes(step) ? 'seen' : ''} ${current === step ? 'expanded' : ''}`}
style={{left: `${step.x}%`, top: `${step.y}%`}}
onMouseOver={this._onMouseOverOverlay}
>
<div
className="overlay-content"
style={{backgroundPosition: `${step.x - 3.0}% ${step.y - 3.0}%`}}
>
</div>
</div>
)}
</div>
</div>
<div className="right">
<img src={`nylas://onboarding/assets/${current.image}`} style={{zoom: 0.6, margin: 'auto'}} role="presentation" />
<h2>{current.title}</h2>
<p>{current.description}</p>
</div>
</div>
<div className="footer">
<button key="prev" className="btn btn-large btn-prev" onClick={this._onBack}>
Back
</button>
<button key="next" className="btn btn-large btn-next" onClick={this._onNextUnseen}>
{seen.length < Steps.length ? 'Next' : 'Get Started'}
</button>
</div>
</div>
);
}
}

View file

@ -0,0 +1,47 @@
import React from 'react';
import {Actions, AccountStore} from 'nylas-exports';
import {RetinaImg} from 'nylas-component-kit';
import OnboardingActions from './onboarding-actions';
export default class WelcomePage extends React.Component {
static displayName = "WelcomePage";
_onContinue = () => {
Actions.recordUserEvent('Welcome Page Finished');
OnboardingActions.moveToPage("tutorial");
}
_renderContent(isFirstAccount) {
if (isFirstAccount) {
return (
<div>
<RetinaImg className="logo" style={{marginTop: 166}} url="nylas://onboarding/assets/nylas-logo@2x.png" mode={RetinaImg.Mode.ContentPreserve} />
<p className="hero-text" style={{fontSize: 46, marginTop: 57}}>Welcome to Nylas N1</p>
<RetinaImg className="icons" url="nylas://onboarding/assets/icons-bg@2x.png" mode={RetinaImg.Mode.ContentPreserve} />
</div>
)
}
return (
<div>
<p className="hero-text" style={{fontSize: 46, marginTop: 187}}>Welcome back!</p>
<p className="hero-text" style={{fontSize: 20, maxWidth: 550, margin: 'auto', lineHeight: 1.7, marginTop: 30}}>This month we're <a href="https://nylas.com/blog/nylas-pro/">launching Nylas Pro</a>. As an existing user, you'll receive a coupon for your first year free. Create a Nylas ID to continue using N1, and look out for a coupon email!</p>
<RetinaImg className="icons" url="nylas://onboarding/assets/icons-bg@2x.png" mode={RetinaImg.Mode.ContentPreserve} />
</div>
)
}
render() {
const isFirstAccount = (AccountStore.accounts().length === 0);
return (
<div className={`page welcome is-first-account-${isFirstAccount}`}>
<div className="steps-container">
{this._renderContent(isFirstAccount)}
</div>
<div className="footer">
<button key="next" className="btn btn-large btn-continue" onClick={this._onContinue}>Get Started</button>
</div>
</div>
);
}
}

View file

@ -1,58 +0,0 @@
nodeRequest = require 'request'
{Actions, Utils, APIError} = require 'nylas-exports'
class TokenAuthAPI
constructor: ->
NylasEnv.config.onDidChange('env', @_onConfigChanged)
@_onConfigChanged()
@
_onConfigChanged: =>
env = NylasEnv.config.get('env')
if env is 'development'
@APIRoot = "http://localhost:5000"
else if env in ['experimental', 'staging']
@APIRoot = "https://invite-staging.nylas.com"
else
@APIRoot = "https://invite.nylas.com"
request: (options={}) ->
return if NylasEnv.getLoadSettings().isSpec
options.method ?= 'GET'
options.url ?= "#{@APIRoot}#{options.path}" if options.path
options.body ?= {} unless options.formData
options.json = true
options.error ?= @_defaultErrorCallback
# This is to provide functional closure for the variable.
rid = Utils.generateTempId()
[rid].forEach (requestId) ->
options.startTime = Date.now()
Actions.willMakeAPIRequest({
request: options,
requestId: requestId
})
nodeRequest options, (error, response, body) ->
statusCode = response?.statusCode
Actions.didMakeAPIRequest({
request: options,
statusCode: statusCode,
error: error,
requestId: requestId
})
if error? or statusCode > 299
if not statusCode or statusCode in [-123, 500]
body = "Sorry, we could not reach the Nylas API. Please try
again, or email us if you continue to have trouble.
(#{statusCode})"
options.error(new APIError({error:error, response:response, body:body, requestOptions: options}))
else
options.success(body) if options.success
_defaultErrorCallback: (apiError) ->
console.error(apiError)
module.exports = new TokenAuthAPI

View file

@ -1,158 +0,0 @@
React = require 'react'
ReactDOM = require 'react-dom'
ReactCSSTransitionGroup = require 'react-addons-css-transition-group'
_ = require 'underscore'
{RetinaImg} = require 'nylas-component-kit'
{Utils} = require 'nylas-exports'
TokenAuthAPI = require './token-auth-api'
OnboardingActions = require './onboarding-actions'
PageRouterStore = require './page-router-store'
Providers = require './account-types'
url = require 'url'
class TokenAuthPage extends React.Component
@displayName: "TokenAuthPage"
constructor: (@props) ->
@state =
token: ""
tokenValidityError: null
tokenAuthInflight: false
tokenAuthEnabled: PageRouterStore.tokenAuthEnabled()
tokenAuthEnabledError: PageRouterStore.tokenAuthEnabledError()
componentDidMount: ->
@_usub = PageRouterStore.listen(@_onTokenAuthChange)
_onTokenAuthChange: =>
@setState
tokenAuthEnabled: PageRouterStore.tokenAuthEnabled()
tokenAuthEnabledError: PageRouterStore.tokenAuthEnabledError()
@_resize()
componentWillUnmount: ->
@_usub?()
render: =>
if @state.tokenAuthEnabled is "unknown"
<div className="page token-auth">
<ReactCSSTransitionGroup transitionLeaveTimeout={150} transitionEnterTimeout={150} transitionName="alpha-fade">
{@_renderWaitingForTokenAuthAnswer()}
</ReactCSSTransitionGroup>
</div>
else if @state.tokenAuthEnabled is "yes"
<div className="page token-auth token-auth-enabled">
<div className="quit" onClick={ -> OnboardingActions.closeWindow() }>
<RetinaImg name="onboarding-close.png" mode={RetinaImg.Mode.ContentPreserve}/>
</div>
<RetinaImg url="nylas://onboarding/assets/nylas-pictograph@2x.png" mode={RetinaImg.Mode.ContentIsMask} style={zoom: 0.29} className="logo"/>
<div className="caption" style={padding: 40}>
Due to overwhelming interest, you need an invitation code to connect
an account to N1. Enter your invitation code below, or <a href="https://invite.nylas.com">request one here</a>.
</div>
{@_renderContinueError()}
<label className="token-label">
{@_renderInput()}
</label>
{@_renderContinueButton()}
</div>
else
<div className="page token-auth">
</div>
_renderWaitingForTokenAuthAnswer: =>
if @state.tokenAuthEnabledError
<div style={position:'absolute', width:'100%', padding:60, paddingTop:135} key="error">
<div className="errormsg">{@state.tokenAuthEnabledError}</div>
<button key="retry"
style={marginTop: 15}
className="btn btn-large btn-retry"
onClick={OnboardingActions.retryCheckTokenAuthStatus}>
Try Again
</button>
</div>
else
<div style={position:'absolute', width:'100%'} key="spinner">
<RetinaImg url="nylas://onboarding/assets/installing-spinner.gif"
mode={RetinaImg.Mode.ContentPreserve}
style={marginTop: 190}/>
</div>
_renderInput: =>
if @state.errorMessage
<input type="text"
value={@state.token}
onChange={@_onTokenChange}
onKeyPress={@_onKeyPress}
placeholder="Invitation Code"
className="token-input error" />
else
<input type="text"
value={@state.token}
onChange={@_onTokenChange}
onKeyPress={@_onKeyPress}
placeholder="Invitation Code"
className="token-input" />
_renderContinueButton: =>
if @state.tokenAuthInflight
<button className="btn btn-large btn-disabled" type="button">
<RetinaImg name="sending-spinner.gif" width={15} height={15} mode={RetinaImg.Mode.ContentPreserve} /> Checking&hellip;
</button>
else
<button className="btn btn-large btn-gradient" type="button" onClick={@_onContinue}>Continue</button>
_renderContinueError: =>
if @state.tokenValidityError
<div className="errormsg" role="alert">
{@state.tokenValidityError}
</div>
else
<div></div>
_onTokenChange: (event) =>
@setState(token: event.target.value)
_onKeyPress: (event) =>
if event.key in ['Enter', 'Return']
@_onContinue()
_onContinue: =>
if @state.tokenAuthInflight
return
if not @state.token or not /^[\w]{32}$/.test(@state.token)
@setState({
tokenAuthInflight: false,
tokenValidityError: "Please enter a valid invitation code."
})
@_resize()
return
@setState({tokenAuthInflight: true})
TokenAuthAPI.request
path: "/token/#{@state.token}"
returnsModel: false
timeout: 30000
success: (json) =>
NylasEnv.config.set("invitationCode", @state.token)
OnboardingActions.moveToPage("account-choose")
error: (err) =>
_.delay =>
@setState
tokenValidityError: err.message
tokenAuthInflight: false
@_resize()
, 400
_resize: =>
setTimeout( =>
@props.onResize?()
,10)
module.exports = TokenAuthPage

View file

@ -1,125 +0,0 @@
React = require 'react'
{shell} = require 'electron'
classnames = require 'classnames'
{Actions} = require 'nylas-exports'
{RetinaImg} = require 'nylas-component-kit'
PageRouterStore = require './page-router-store'
OnboardingActions = require './onboarding-actions'
class WelcomePage extends React.Component
@displayName: "WelcomePage"
constructor: (@props) ->
@state =
step: 0
lastStep: 0
render: ->
<div className="welcome-page page opaque">
<div className="quit" onClick={ -> OnboardingActions.closeWindow() }>
<RetinaImg name="onboarding-close.png" mode={RetinaImg.Mode.ContentPreserve}/>
</div>
<div className="steps-container">{@_renderSteps()}</div>
<div className="footer step-#{@state.step}">{@_renderButtons()}</div>
</div>
_renderButtons: ->
buttons = []
btnText = ""
if @state.step is 0
btnText = "Lets get started"
else if @state.step is 1
btnText = "Continue"
else if @state.step is 2
btnText = "Get started"
buttons.push <button key="next" className="btn btn-large btn-continue" onClick={@_onContinue}>{btnText}</button>
return buttons
_renderSteps: -> [
@_renderStep0()
@_renderStep1()
@_renderStep2()
]
_stepClass: (n) ->
obj =
"step-wrap": true
"active": @state.step is n
obj["step-#{n}-wrap"] = true
className = classnames(obj)
return className
_renderStep0: ->
<div className={@_stepClass(0)} key="step-0">
<RetinaImg className="logo" style={marginTop: 86} url="nylas://onboarding/assets/nylas-logo@2x.png" mode={RetinaImg.Mode.ContentPreserve}/>
<p className="hero-text" style={fontSize: 46, marginTop: 57}>Welcome to Nylas N1</p>
<RetinaImg className="icons" style={position: "absolute", top: 0, left: 0} url="nylas://onboarding/assets/icons-bg@2x.png" mode={RetinaImg.Mode.ContentPreserve} />
{@_renderNavBubble(0)}
</div>
_renderStep1: ->
<div className={@_stepClass(1)} key="step-1">
<p className="hero-text" style={marginTop: 40}>Open source & made for developers.</p>
<div className="gear-outer-container"><div className="gear-container">
{@_gears()}
</div></div>
<RetinaImg className="gear-small" mode={RetinaImg.Mode.ContentPreserve}
url="nylas://onboarding/assets/gear-small@2x.png" />
<RetinaImg className="wrench" mode={RetinaImg.Mode.ContentPreserve}
url="nylas://onboarding/assets/wrench@2x.png" />
<p className="sub-text">Nylas N1 and the cloud sync engine are available on <a onClick={=> @_open("https://github.com/nylas/n1")}>GitHub</a></p>
{@_renderNavBubble(1)}
</div>
_gears: ->
gears = []
gear = "gear-large@2x.png"
for i in [0..3]
if i isnt 0 then gear = "gear-large-outer@2x.png"
gears.push <RetinaImg className="gear-large gear-large-#{i}"
mode={RetinaImg.Mode.ContentPreserve}
url="nylas://onboarding/assets/#{gear}" />
return gears
_renderStep2: ->
<div className={@_stepClass(2)} key="step-2">
<p className="hero-text" style={marginTop: 26}>Powered by Cloud Sync.</p>
<RetinaImg mode={RetinaImg.Mode.ContentPreserve}
style={paddingTop: 4, paddingBottom: 4}
url="nylas://onboarding/assets/cloud@2x.png" />
<p style={fontSize: 17, opacity: 0.7, marginTop: 18}>Nylas syncs your mail in the cloud. This makes N1 blazing fast<br/>and is needed for features like Snooze and Send Later.</p>
<p><a onClick={=> @_open("https://github.com/nylas/sync-engine")}>Learn more</a></p>
{@_renderNavBubble(2)}
</div>
_open: (link) ->
shell.openExternal(link)
return
_renderNavBubble: (step=0) ->
bubbles = [0..2].map (n) =>
active = if n is step then "active" else ""
<div className="nav-bubble #{active}"
onClick={ => @setState step: n }></div>
<div className="nav-bubbles">
{bubbles}
</div>
_onBack: =>
@setState(step: @state.step - 1)
_onContinue: =>
if @state.step < 2
@setState(step: @state.step + 1)
else
Actions.recordUserEvent('Welcome Page Finished', {
tokenAuthEnabled: PageRouterStore.tokenAuthEnabled(0)
})
if PageRouterStore.tokenAuthEnabled() is "no"
OnboardingActions.moveToPage("account-choose")
else
OnboardingActions.moveToPage("token-auth")
module.exports = WelcomePage

View file

@ -1,35 +0,0 @@
Actions = require '../lib/onboarding-actions'
NylasApiEnvironmentStore = require '../lib/nylas-api-environment-store'
storeConstructor = NylasApiEnvironmentStore.constructor
describe "NylasApiEnvironmentStore", ->
beforeEach ->
spyOn(NylasEnv.config, "set")
it "doesn't set if it alreayd exists", ->
spyOn(NylasEnv.config, "get").andReturn "staging"
store = new storeConstructor()
expect(NylasEnv.config.set).not.toHaveBeenCalled()
it "initializes with the correct default in dev mode", ->
spyOn(NylasEnv, "inDevMode").andReturn true
spyOn(NylasEnv.config, "get").andReturn undefined
store = new storeConstructor()
expect(NylasEnv.config.set).toHaveBeenCalledWith("env", "production")
it "initializes with the correct default in production", ->
spyOn(NylasEnv, "inDevMode").andReturn false
spyOn(NylasEnv.config, "get").andReturn undefined
store = new storeConstructor()
expect(NylasEnv.config.set).toHaveBeenCalledWith("env", "production")
describe "when setting the environment", ->
it "sets from the desired action", ->
Actions.changeAPIEnvironment("staging")
expect(NylasEnv.config.set).toHaveBeenCalledWith("env", "staging")
it "throws if the env is invalid", ->
expect( -> Actions.changeAPIEnvironment("bad")).toThrow()
it "throws if the env is blank", ->
expect( -> Actions.changeAPIEnvironment()).toThrow()

View file

@ -0,0 +1,187 @@
@import "ui-variables";
/* The Onboarding window should never adopt theme styles. This re-assigns UI
variables and resets commonly overridden styles to ensure the onboarding window
always looks good. Previously we tried to make the theme just not load in the
window, but it uses a hot window which makes that difficult now. */
@black: #231f20;
@gray-base: #0a0b0c;
@gray-darker: lighten(@gray-base, 13.5%); // #222
@gray-dark: lighten(@gray-base, 20%); // #333
@gray: lighten(@gray-base, 33.5%); // #555
@gray-light: lighten(@gray-base, 46.7%); // #777
@gray-lighter: lighten(@gray-base, 92.5%); // #eee
@white: #ffffff;
@blue-dark: #3187e1;
@blue: #419bf9;
//== Color Descriptors
@accent-primary: @blue;
@accent-primary-dark: @blue-dark;
@background-primary: @white;
@background-off-primary: #fdfdfd;
@background-secondary: #f6f6f6;
@background-tertiary: #6d7987;
@text-color: @black;
@text-color-subtle: fadeout(@text-color, 20%);
@text-color-very-subtle: fadeout(@text-color, 50%);
@text-color-inverse: @white;
@text-color-inverse-subtle: fadeout(@text-color-inverse, 20%);
@text-color-inverse-very-subtle: fadeout(@text-color-inverse, 50%);
@text-color-heading: #434648;
@font-family-sans-serif: "Nylas-Pro", "Helvetica", sans-serif;
@font-family-serif: Georgia, "Times New Roman", Times, serif;
@font-family-monospace: Menlo, Monaco, Consolas, "Courier New", monospace;
@font-family: @font-family-sans-serif;
@font-family-heading: @font-family-sans-serif;
@font-size-base: 14px;
@line-height-base: 1.5; // 22.5/15
@line-height-computed: floor((@font-size-base * @line-height-base)); // ~20px
@line-height-heading: 1.1;
@component-active-color: @accent-primary-dark;
@component-active-bg: @background-primary;
@input-bg: @white;
@input-bg-disabled: @gray-lighter;
h1, h2, h3, h4, h5, h6 {
font-family: @font-family-heading;
line-height: @line-height-heading;
color: @text-color-heading;
small,
.small {
line-height: 1;
}
}
h1 {
font-size: @font-size-h1;
font-weight: @font-weight-semi-bold;
}
h2 {
font-size: @font-size-h2;
font-weight: @font-weight-blond;
}
h3 {
font-size: @font-size-h3;
font-weight: @font-weight-blond;
}
h4 { font-size: @font-size-h4; }
h5 { font-size: @font-size-h5; }
h6 { font-size: @font-size-h6; }
h1, h2, h3{
margin-top: @line-height-computed;
margin-bottom: (@line-height-computed / 2);
small,
.small {
font-size: 65%;
}
}
h4, h5, h6 {
margin-top: (@line-height-computed / 2);
margin-bottom: (@line-height-computed / 2);
small,
.small {
font-size: 75%;
}
}
.btn {
padding: 0 0.8em;
border-radius: @border-radius-base;
border: 0;
cursor: default;
display:inline-block;
color: @btn-default-text-color;
background: @background-primary;
img.content-mask { background-color: @btn-default-text-color; }
// Use 4 box shadows to create a 0.5px hairline around the button, and another
// for the actual shadow. Pending https://code.google.com/p/chromium/issues/detail?id=236371
// Yes, 1px border looks really bad on retina.
box-shadow: 0 0.5px 0 rgba(0,0,0,0.15), 0 -0.5px 0 rgba(0,0,0,0.15), 0.5px 0 0 rgba(0,0,0,0.15), -0.5px 0 0 rgba(0,0,0,0.15), 0 0.5px 1px rgba(0, 0, 0, 0.15);
height: 1.9em;
line-height: 1.9em;
.text {
margin-left: 6px;
}
&:active {
cursor: default;
background: darken(@btn-default-bg-color, 9%);
}
&:focus {
outline: none
}
font-size: @font-size-small;
&.btn-small {
font-size: @font-size-smaller;
}
&.btn-large {
font-size: @font-size-base;
padding: 0 1.3em;
line-height: 2.2em;
height: 2.3em;
}
&.btn-larger {
font-size: @font-size-large;
padding: 0 1.6em;
}
&.btn-disabled {
color: fadeout(@btn-default-text-color, 40%);
background: fadeout(@btn-default-bg-color, 15%);
&:active {
background: fadeout(@btn-default-bg-color, 15%);
}
}
&.btn-emphasis {
position: relative;
color: @btn-emphasis-text-color;
font-weight: @font-weight-medium;
img.content-mask { background-color:@btn-emphasis-text-color; }
background: linear-gradient(to bottom, #6bb1f9 0%, #0a80ff 100%);
box-shadow: none;
border: 1px solid darken(@btn-emphasis-bg-color, 7%);
&.btn-disabled {
opacity: 0.4;
}
&:before {
content: ' ';
width: calc(~"100% + 2px");
height: calc(~"100% + 2px");
border-radius: @border-radius-base + 1;
top: -1px;
left: -1px;
position: absolute;
z-index: -1;
background: linear-gradient(to bottom, #4ca2f9 0%, #015cff 100%);
}
&:active {
background: -webkit-gradient(linear, left top, left bottom, from(darken(@btn-emphasis-bg-color,10%)), to(darken(@btn-emphasis-bg-color, 4%)));
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -12,7 +12,7 @@ module.exports = class ClearbitDataSource
return Promise.resolve(null)
tok = AccountStore.tokenForAccountId(AccountStore.accounts()[0].id)
new Promise (resolve, reject) =>
EdgehillAPI.request
EdgehillAPI.makeRequest
auth:
user: tok
pass: ""

View file

@ -8,7 +8,7 @@ import PreferencesAccounts from './tabs/preferences-accounts';
import PreferencesAppearance from './tabs/preferences-appearance';
import PreferencesKeymaps from './tabs/preferences-keymaps';
import PreferencesMailRules from './tabs/preferences-mail-rules';
import PreferencesIdentity from './tabs/preferences-identity';
export function activate() {
PreferencesUIStore.registerPreferencesTab(new PreferencesUIStore.TabItem({
@ -23,23 +23,29 @@ export function activate() {
component: PreferencesAccounts,
order: 2,
}))
PreferencesUIStore.registerPreferencesTab(new PreferencesUIStore.TabItem({
tabId: 'Subscription',
displayName: 'Subscription',
component: PreferencesIdentity,
order: 3,
}))
PreferencesUIStore.registerPreferencesTab(new PreferencesUIStore.TabItem({
tabId: 'Appearance',
displayName: 'Appearance',
component: PreferencesAppearance,
order: 3,
order: 4,
}))
PreferencesUIStore.registerPreferencesTab(new PreferencesUIStore.TabItem({
tabId: 'Shortcuts',
displayName: 'Shortcuts',
component: PreferencesKeymaps,
order: 4,
order: 5,
}))
PreferencesUIStore.registerPreferencesTab(new PreferencesUIStore.TabItem({
tabId: 'Mail Rules',
displayName: 'Mail Rules',
component: PreferencesMailRules,
order: 5,
order: 6,
}))
WorkspaceStore.defineSheet('Preferences', {}, {

View file

@ -7,7 +7,6 @@ import PreferencesAccountDetails from './preferences-account-details';
class PreferencesAccounts extends React.Component {
static displayName = 'PreferencesAccounts';
constructor() {

View file

@ -0,0 +1,142 @@
import React from 'react';
import {Actions, IdentityStore} from 'nylas-exports';
import {RetinaImg} from 'nylas-component-kit';
import {shell} from 'electron';
class OpenIdentityPageButton extends React.Component {
static propTypes = {
path: React.PropTypes.string,
label: React.PropTypes.string,
img: React.PropTypes.string,
}
constructor(props) {
super(props);
this.state = {
loading: false,
};
}
_onClick = () => {
IdentityStore.fetchSingleSignOnURL(this.props.path).then((url) => {
this.setState({loading: false});
shell.openExternal(url);
});
}
render() {
if (this.state.loading) {
return (
<div className="btn btn-disabled">
<RetinaImg name="sending-spinner.gif" width={15} height={15} mode={RetinaImg.Mode.ContentPreserve} />
&nbsp;{this.props.label}&hellip;
</div>
);
}
if (this.props.img) {
return (
<div className="btn" onClick={this._onClick}>
<RetinaImg name={this.props.img} mode={RetinaImg.Mode.ContentPreserve} />
&nbsp;&nbsp;{this.props.label}
</div>
)
}
return (
<div className="btn" onClick={this._onClick}>{this.props.label}</div>
);
}
}
class PreferencesIdentity extends React.Component {
static displayName = 'PreferencesIdentity';
constructor() {
super();
this.state = this.getStateFromStores();
}
componentDidMount() {
this.unsubscribe = IdentityStore.listen(() => {
this.setState(this.getStateFromStores());
});
}
componentWillUnmount() {
this.unsubscribe();
}
getStateFromStores() {
return {
identity: IdentityStore.identity(),
subscriptionState: IdentityStore.subscriptionState(),
trialDaysRemaining: IdentityStore.trialDaysRemaining(),
};
}
_renderPaymentRow() {
const {identity, trialDaysRemaining, subscriptionState} = this.state
if (subscriptionState === IdentityStore.SubscriptionState.Trialing) {
return (
<div className="row payment-row">
<div>
There {(trialDaysRemaining > 1) ? `are ${trialDaysRemaining} days ` : `is one day `}
remaining in your 30-day trial of Nylas Pro.
</div>
<OpenIdentityPageButton img="ic-upgrade.png" label="Upgrade to Nylas Pro" path="/dashboard#subscription" />
</div>
)
}
if (subscriptionState === IdentityStore.SubscriptionState.Lapsed) {
return (
<div className="row payment-row">
<div>
Your subscription has been cancelled or your billing information has expired.
We've paused your mailboxes! Re-new your subscription to continue using N1.
</div>
<OpenIdentityPageButton img="ic-upgrade.png" label="Update Subscription" path="/dashboard#subscription" />
</div>
)
}
return (
<div className="row payment-row">
<div>
Your subscription will renew on {new Date(identity.valid_until).toLocaleDateString()}. Enjoy N1!
</div>
</div>
)
}
render() {
const {identity} = this.state
const {firstname, lastname, email} = identity
return (
<div className="container-identity">
<div className="id-header">Nylas ID:</div>
<div className="identity-content-box">
<div className="row info-row">
<div className="logo">
<RetinaImg
name="prefs-identity-nylas-logo.png"
mode={RetinaImg.Mode.ContentPreserve}
/>
</div>
<div className="identity-info">
<div className="name">{firstname} {lastname}</div>
<div className="email">{email}</div>
<div className="identity-actions">
<OpenIdentityPageButton label="Account Details" path="/dashboard" />
<div className="btn" onClick={() => Actions.logoutNylasIdentity()}>Sign Out</div>
</div>
</div>
</div>
{this._renderPaymentRow()}
</div>
</div>
);
}
}
export default PreferencesIdentity;

View file

@ -0,0 +1,63 @@
@import "ui-variables";
.container-identity {
width: 50%;
margin: auto;
padding-top: @padding-base-vertical * 2;
.id-header {
color: @text-color-very-subtle;
margin-bottom: @padding-base-vertical * 2;
}
.identity-content-box {
display: flex;
flex-direction: column;
align-items: flex-start;
color: @text-color-subtle;
border-radius: @border-radius-large;
border: 1px solid @border-color-primary;
background-color: @background-secondary;
.row {
display: flex;
align-items: center;
width: 100%;
}
.payment-row {
flex-direction: column;
align-items: flex-start;
border-top: 1px solid @border-color-primary;
padding: 20px;
padding-left: 137px;
&>div:first-child {
margin-bottom: @padding-base-vertical * 2;
}
}
.info-row {
padding: 30px;
.logo {
margin-right: 30px;
}
.identity-info {
line-height: 1.9em;
.name {
font-size: 1.2em;
}
.identity-actions {
margin-top: @padding-small-vertical + 1;
.btn {
width: 150px;
text-align: center;
&:first-child {
margin-right: @padding-base-horizontal;
}
}
}
}
}
}
}

View file

@ -19,6 +19,7 @@
"babel-preset-react": "6.5.0",
"babel-regenerator-runtime": "6.5.0",
"bluebird": "^2.9",
"chromium-net-errors": "1.0.3",
"chrono-node": "^1.1.2",
"classnames": "1.2.1",
"coffee-react": "^2.0.0",

View file

@ -67,11 +67,11 @@ describe('Clean app boot', ()=> {
// Monkeypatch NylasAPI and EdgehillAPI
const json = JSON.parse(jsonStr);
$n._nylasApiMakeRequest = $n.NylasAPI.makeRequest;
$n._edgehillRequest = $n.EdgehillAPI.request;
$n._edgehillRequest = $n.EdgehillAPI.makeRequest;
$n.NylasAPI.makeRequest = ()=> {
return Promise.resolve(json);
};
$n.EdgehillAPI.request = ({success})=> {
$n.EdgehillAPI.makeRequest = ({success})=> {
success(json);
};
}, fakeAccountJson)

View file

@ -82,8 +82,6 @@ export default class Application extends EventEmitter {
}
const exampleNewNames = {
'N1-Composer-Templates': 'composer-templates',
'N1-Composer-Translate': 'composer-translate',
'N1-Message-View-on-Github': 'message-view-on-github',
'N1-Personal-Level-Indicators': 'personal-level-indicators',
'N1-Phishing-Detection': 'phishing-detection',
@ -192,32 +190,39 @@ export default class Application extends EventEmitter {
openWindowsForTokenState(loadingMessage) {
const accounts = this.config.get('nylas.accounts');
const hasAccount = accounts && accounts.length > 0;
if (hasAccount) {
const hasN1ID = this.config.get('nylas.identity');
if (hasAccount && hasN1ID) {
this.windowManager.ensureWindow(WindowManager.MAIN_WINDOW, {loadingMessage});
this.windowManager.ensureWindow(WindowManager.WORK_WINDOW);
} else {
this.windowManager.ensureWindow(WindowManager.ONBOARDING_WINDOW, {
title: "Welcome to N1",
windowProps: {
page: "welcome",
},
});
this.windowManager.ensureWindow(WindowManager.WORK_WINDOW);
}
}
_resetConfigAndRelaunch = () => {
_relaunchToInitialWindows = ({resetConfig, resetDatabase} = {}) => {
this.setDatabasePhase('close');
this.windowManager.destroyAllWindows();
this._deleteDatabase(() => {
this.config.set('nylas', null);
this.config.set('edgehill', null);
let fn = (callback) => callback()
if (resetDatabase) {
fn = this._deleteDatabase;
}
fn(() => {
if (resetConfig) {
this.config.set('nylas', null);
this.config.set('edgehill', null);
}
this.setDatabasePhase('setup');
this.openWindowsForTokenState();
});
}
_deleteDatabase(callback) {
_deleteDatabase = (callback) => {
this.deleteFileWithRetry(path.join(this.configDirPath, 'edgehill.db'), callback);
this.deleteFileWithRetry(path.join(this.configDirPath, 'edgehill.db-wal'));
this.deleteFileWithRetry(path.join(this.configDirPath, 'edgehill.db-shm'));
@ -293,7 +298,7 @@ export default class Application extends EventEmitter {
});
});
this.on('application:reset-config-and-relaunch', this._resetConfigAndRelaunch);
this.on('application:relaunch-to-initial-windows', this._relaunchToInitialWindows);
this.on('application:quit', () => {
app.quit()
@ -308,14 +313,18 @@ export default class Application extends EventEmitter {
});
this.on('application:add-account', ({existingAccount} = {}) => {
this.windowManager.ensureWindow(WindowManager.ONBOARDING_WINDOW, {
title: "Add an Account",
windowProps: {
page: "account-choose",
pageData: {existingAccount},
},
})
const onboarding = this.windowManager.get(WindowManager.ONBOARDING_WINDOW);
if (onboarding) {
onboarding.show();
onboarding.focus();
} else {
this.windowManager.ensureWindow(WindowManager.ONBOARDING_WINDOW, {
title: "Add an Account",
windowProps: { addingAccount: true, existingAccount },
});
}
});
this.on('application:new-message', () => {
this.windowManager.sendToWindow(WindowManager.MAIN_WINDOW, 'new-message');
});

View file

@ -206,6 +206,8 @@ export default class WindowManager {
frame: false, // Always false on Mac, explicitly set for Win & Linux
toolbar: false,
resizable: false,
width: 900,
height: 580,
}
// The SPEC_WINDOW gets passed its own bootstrapScript

View file

@ -2,6 +2,7 @@ React = require 'react'
ReactDOM = require 'react-dom'
{Utils,
RegExpUtils,
IdentityStore,
SearchableComponentMaker,
SearchableComponentStore}= require 'nylas-exports'
IFrameSearcher = require('../searchable-components/iframe-searcher').default
@ -158,6 +159,14 @@ class EventedIFrame extends React.Component
e.preventDefault()
# 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)
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.
# The `target` comes from the document context of the iframe, which
# as of Electron 0.36.9, has different constructor function objects

View file

@ -29,7 +29,7 @@ class NewsletterSignup extends React.Component
_onGetStatus: (props = @props) =>
@_setState({status: 'Pending'})
EdgehillAPI.request
EdgehillAPI.makeRequest
method: 'GET'
path: @_path(props)
success: (status) =>
@ -42,7 +42,7 @@ class NewsletterSignup extends React.Component
_onSubscribe: =>
@_setState({status: 'Pending'})
EdgehillAPI.request
EdgehillAPI.makeRequest
method: 'POST'
path: @_path()
success: (status) =>
@ -52,7 +52,7 @@ class NewsletterSignup extends React.Component
_onUnsubscribe: =>
@_setState({status: 'Pending'})
EdgehillAPI.request
EdgehillAPI.makeRequest
method: 'DELETE'
path: @_path()
success: (status) =>

View file

@ -152,6 +152,12 @@ class Actions
###
@clearDeveloperConsole: ActionScopeWindow
###
Public: Manage the Nylas identity
###
@setNylasIdentity: ActionScopeWindow
@logoutNylasIdentity: ActionScopeWindow
###
Public: Remove the selected account

View file

@ -1,61 +0,0 @@
_ = require 'underscore'
nodeRequest = require 'request'
Utils = require './models/utils'
Actions = require './actions'
{APIError} = require './errors'
DatabaseStore = require './stores/database-store'
PriorityUICoordinator = require '../priority-ui-coordinator'
async = require 'async'
# TODO: Fold this code into NylasAPI or create a base class
class EdgehillAPI
constructor: ->
NylasEnv.config.onDidChange('env', @_onConfigChanged)
@_onConfigChanged()
@
_onConfigChanged: =>
env = NylasEnv.config.get('env')
if env in ['development', 'local']
@APIRoot = "http://localhost:5009"
else if env is 'experimental'
@APIRoot = "https://edgehill-experimental.nylas.com"
else if env is 'staging'
@APIRoot = "https://n1-auth-staging.nylas.com"
else
@APIRoot = "https://n1-auth.nylas.com"
request: (options={}) ->
return if NylasEnv.getLoadSettings().isSpec
options.method ?= 'GET'
options.url ?= "#{@APIRoot}#{options.path}" if options.path
options.body ?= {} unless options.formData
options.json = true
options.error ?= @_defaultErrorCallback
# This is to provide functional closure for the variable.
rid = Utils.generateTempId()
[rid].forEach (requestId) ->
options.startTime = Date.now()
Actions.willMakeAPIRequest({
request: options,
requestId: requestId
})
nodeRequest options, (error, response, body) ->
Actions.didMakeAPIRequest({
request: options,
statusCode: response?.statusCode,
error: error,
requestId: requestId
})
PriorityUICoordinator.settle.then ->
if error? or response.statusCode > 299
options.error(new APIError({error:error, response:response, body:body, requestOptions: options}))
else
options.success(body, response) if options.success
_defaultErrorCallback: (apiError) ->
console.error(apiError)
module.exports = new EdgehillAPI

38
src/flux/edgehill-api.es6 Normal file
View file

@ -0,0 +1,38 @@
import NylasAPIRequest from './nylas-api-request';
class EdgehillAPI {
constructor() {
NylasEnv.config.onDidChange('env', this._onConfigChanged);
this._onConfigChanged();
}
_onConfigChanged = () => {
const env = NylasEnv.config.get('env')
if (['development', 'local'].includes(env)) {
this.APIRoot = "http://localhost:5009";
} else if (env === 'experimental') {
this.APIRoot = "https://edgehill-experimental.nylas.com";
} else if (env === 'staging') {
this.APIRoot = "https://n1-auth-staging.nylas.com";
} else {
this.APIRoot = "https://n1-auth.nylas.com";
}
}
makeRequest(options = {}) {
if (NylasEnv.getLoadSettings().isSpec) {
return Promise.resolve();
}
options.auth = options.auth || {
user: '',
pass: '',
sendImmediately: true,
};
const req = new NylasAPIRequest(this, options);
return req.run();
}
}
export default new EdgehillAPI;

View file

@ -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',
}),
subscriptionRequiredAfter: Attributes.DateTime({
modelKey: 'subscriptionRequiredAfter',
jsonKey: 'pro_subscription_required_after',
}),
});
constructor(args) {

View file

@ -0,0 +1,108 @@
import request from 'request'
import Utils from './models/utils'
import Actions from './actions'
import {APIError} from './errors'
import PriorityUICoordinator from '../priority-ui-coordinator'
import IdentityStore from './stores/identity-store'
import NylasAPI from './nylas-api'
export default class NylasAPIRequest {
constructor(api, options) {
const defaults = {
url: `${api.APIRoot}${options.path}`,
method: 'GET',
json: true,
timeout: 15000,
started: () => {},
error: () => {},
success: () => {},
}
this.api = api;
this.options = Object.assign(defaults, options);
if (this.options.method !== 'GET' || this.options.formData) {
this.options.body = this.options.body || {};
}
}
constructAuthHeader() {
if (!this.options.accountId) {
throw new Error("Cannot make Nylas request without specifying `auth` or an `accountId`.");
}
const identity = IdentityStore.identity();
if (identity && !identity.token) {
throw new Error("Identity is present but identity token is missing.");
}
const accountToken = this.api.accessTokenForAccountId(this.options.accountId);
if (!accountToken) {
throw new Error(`Cannot make Nylas request for account ${this.options.accountId} auth token.`);
}
return {
user: accountToken,
pass: identity ? identity.token : '',
sendImmediately: true,
};
}
run() {
if (!this.options.auth) {
try {
this.options.auth = this.constructAuthHeader();
} catch (err) {
return Promise.reject(new APIError({body: err.message, statusCode: 400}));
}
}
const requestId = Utils.generateTempId();
return new Promise((resolve, reject) => {
this.options.startTime = Date.now();
Actions.willMakeAPIRequest({
request: this.options,
requestId: requestId,
});
const req = request(this.options, (error, response = {}, body) => {
Actions.didMakeAPIRequest({
request: this.options,
statusCode: response.statusCode,
error: error,
requestId: requestId,
});
PriorityUICoordinator.settle.then(() => {
if (error || (response.statusCode > 299)) {
// Some errors (like socket errors and some types of offline
// errors) return with a valid `error` object but no `response`
// object (and therefore no `statusCode`. To normalize all of
// this, we inject our own offline status code so people down
// the line can have a more consistent interface.
if (!response.statusCode) {
response.statusCode = NylasAPI.TimeoutErrorCodes[0];
}
const apiError = new APIError({error, response, body, requestOptions: this.options});
NylasEnv.errorLogger.apiDebug(apiError);
this.options.error(apiError);
reject(apiError);
} else {
this.options.success(body, response);
resolve(body);
}
});
});
req.on('abort', () => {
const cancelled = new APIError({
statusCode: NylasAPI.CancelledErrorCode,
body: 'Request Aborted',
});
reject(cancelled);
});
this.options.started(req);
});
}
}

View file

@ -1,10 +1,10 @@
_ = require 'underscore'
{remote} = require 'electron'
request = require 'request'
{remote, shell} = require 'electron'
NylasLongConnection = require('./nylas-long-connection').default
Utils = require './models/utils'
Account = require('./models/account').default
Message = require('./models/message').default
IdentityStore = require('./stores/identity-store').default
Actions = require './actions'
{APIError} = require './errors'
PriorityUICoordinator = require '../priority-ui-coordinator'
@ -20,6 +20,7 @@ SampleTemporaryErrorCode = 504
# This is lazy-loaded
AccountStore = null
NylasAPIRequest = null
class NylasAPIChangeLockTracker
constructor: ->
@ -45,77 +46,6 @@ class NylasAPIChangeLockTracker
console.log("The following models are locked:")
console.log(@_locks)
class NylasAPIRequest
constructor: (@api, @options) ->
@options.method ?= 'GET'
@options.url ?= "#{@api.APIRoot}#{@options.path}" if @options.path
@options.json ?= true
@options.timeout ?= 15000
unless @options.method is 'GET' or @options.formData
@options.body ?= {}
@
run: ->
if not @options.auth
if not @options.accountId
err = new APIError(statusCode: 400, body: "Cannot make Nylas request without specifying `auth` or an `accountId`.")
return Promise.reject(err)
token = @api.accessTokenForAccountId(@options.accountId)
if not token
err = new APIError(statusCode: 400, body: "Cannot make Nylas request for account #{@options.accountId} auth token.")
return Promise.reject(err)
@options.auth =
user: token
pass: ''
sendImmediately: true
requestId = Utils.generateTempId()
new Promise (resolve, reject) =>
@options.startTime = Date.now()
Actions.willMakeAPIRequest({
request: @options,
requestId: requestId
})
req = request @options, (error, response, body) =>
Actions.didMakeAPIRequest({
request: @options,
statusCode: response?.statusCode,
error: error,
requestId: requestId
})
PriorityUICoordinator.settle.then =>
if error or response.statusCode > 299
# Some errors (like socket errors and some types of offline
# errors) return with a valid `error` object but no `response`
# object (and therefore no `statusCode`. To normalize all of
# this, we inject our own offline status code so people down
# the line can have a more consistent interface.
if not response?.statusCode
response ?= {}
response.statusCode = TimeoutErrorCodes[0]
apiError = new APIError({error, response, body, requestOptions: @options})
NylasEnv.errorLogger.apiDebug(apiError)
@options.error?(apiError)
reject(apiError)
else
@options.success?(body)
resolve(body)
req.on 'abort', ->
cancelled = new APIError
statusCode: CancelledErrorCode,
body: 'Request Aborted'
reject(cancelled)
@options.started?(req)
class NylasAPI
TimeoutErrorCodes: TimeoutErrorCodes
@ -184,6 +114,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)
@ -193,18 +126,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)
req = new NylasAPIRequest(@, options)
req.run().then(success, error)
longConnection: (opts) ->

View file

@ -147,7 +147,9 @@ class AccountStore extends NylasStore
if remainingAccounts.length is 0
ipc = require('electron').ipcRenderer
ipc.send('command', 'application:reset-config-and-relaunch')
ipc.send('command', 'application:relaunch-to-initial-windows', {
resetDatabase: true,
})
_onReorderAccount: (id, newIdx) =>
existingIdx = _.findIndex @_accounts, (a) -> a.id is id

View file

@ -0,0 +1,151 @@
import NylasStore from 'nylas-store';
import keytar from 'keytar';
import {ipcRenderer} from 'electron';
import request from 'request';
import Actions from '../actions';
import AccountStore from './account-store';
const configIdentityKey = "nylas.identity";
const keytarServiceName = 'Nylas';
const keytarIdentityKey = 'Nylas Account';
const URLRoot = "https://billing-staging.nylas.com";
const State = {
Trialing: 'Trialing',
Valid: 'Valid',
Lapsed: 'Lapsed',
};
class IdentityStore extends NylasStore {
constructor() {
super();
this.listenTo(AccountStore, () => { this.trigger() });
this.listenTo(Actions.setNylasIdentity, this._onSetNylasIdentity);
this.listenTo(Actions.logoutNylasIdentity, this._onLogoutNylasIdentity);
NylasEnv.config.onDidChange(configIdentityKey, () => {
this._loadIdentity();
this.trigger();
});
this._loadIdentity();
if (NylasEnv.isWorkWindow() && ['staging', 'production'].includes(NylasEnv.config.get('env'))) {
setInterval(this.refreshStatus, 1000 * 60 * 60);
this.refreshStatus();
}
}
_loadIdentity() {
this._identity = NylasEnv.config.get(configIdentityKey);
if (this._identity) {
this._identity.token = keytar.getPassword(keytarServiceName, keytarIdentityKey);
}
}
get State() {
return State;
}
get URLRoot() {
return URLRoot;
}
identity() {
return this._identity;
}
subscriptionState() {
if (!this._identity || (this._identity.valid_until === null)) {
return State.Trialing;
}
if (new Date(this._identity.valid_until) < new Date()) {
return State.Lapsed;
}
return State.Valid;
}
trialDaysRemaining() {
const daysToDate = (date) =>
Math.max(0, Math.round((date.getTime() - Date.now()) / (1000 * 24 * 60 * 60)))
if (this.subscriptionState() !== State.Trialing) {
return null;
}
// Return the smallest number of days left in any linked account, or null
// if no trialExpirationDate is present on any account.
return AccountStore.accounts().map((a) =>
(a.subscriptionRequiredAfter ? daysToDate(a.subscriptionRequiredAfter) : null)
).sort().shift();
}
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 = () => {
keytar.deletePassword(keytarServiceName, keytarIdentityKey);
NylasEnv.config.unset(configIdentityKey);
ipcRenderer.send('command', 'application:relaunch-to-initial-windows');
}
_onSetNylasIdentity = (identity) => {
keytar.replacePassword(keytarServiceName, keytarIdentityKey, identity.token);
delete identity.token;
NylasEnv.config.set(configIdentityKey, identity);
}
}
export default new IdentityStore()

View file

@ -73,7 +73,7 @@ Any plugins you used in your sent message will not be available.`
performRemote() {
return new Promise((resolve) => {
EdgehillAPI.request({
EdgehillAPI.makeRequest({
method: "POST",
path: "/plugins/send-successful",
body: {

View file

@ -122,6 +122,7 @@ class NylasExports
@lazyLoadAndRegisterStore "AccountStore", 'account-store'
@lazyLoadAndRegisterStore "MessageStore", 'message-store'
@lazyLoadAndRegisterStore "ContactStore", 'contact-store'
@lazyLoadAndRegisterStore "IdentityStore", 'identity-store'
@lazyLoadAndRegisterStore "MetadataStore", 'metadata-store'
@lazyLoadAndRegisterStore "CategoryStore", 'category-store'
@lazyLoadAndRegisterStore "UndoRedoStore", 'undo-redo-store'

View file

@ -17,6 +17,10 @@ mousetrap.prototype.stopCallback = (e, element, combo) => {
return true;
}
const withinTextInput = element.tagName === 'INPUT' || element.tagName === 'SELECT' || element.tagName === 'TEXTAREA' || element.isContentEditable
const withinWebview = element.tagName === 'WEBVIEW';
if (withinWebview) {
return true;
}
if (withinTextInput) {
const isPlainKey = !/(mod|command|ctrl)/.test(combo);
const isReservedTextEditingShortcut = /(mod|command|ctrl)\+(a|x|c|v)/.test(combo);

View file

@ -125,11 +125,6 @@ class ThemeManager
console.warn("Enabled theme '#{themeName}' is not installed.")
false
# Do not load user themes into the onboarding window, because it uses
# a wide range of hard-coded colors and assets and should always be on-brand.
if NylasEnv.getWindowType() is 'onboarding'
themeNames = []
# Use a built-in theme any time the configured themes are not
# available.
if themeNames.length is 0

Binary file not shown.

After

Width:  |  Height:  |  Size: 791 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB