feat(pro): New Nylas identity provider, onboarding and auth
commit50d0cfb87c
Author: Ben Gotow <bengotow@gmail.com> Date: Fri May 27 14:01:49 2016 -0700 IdentityStore conveniene methods for subscription state commit80c3c7b956
Author: Ben Gotow <bengotow@gmail.com> Date: Fri May 27 12:03:53 2016 -0700 Periodically refresh identity, show expired notice in top bar commit5dc39efe98
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 commit4c4f463f4b
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 commit906ea74807
Author: Ben Gotow <bengotow@gmail.com> Date: Thu May 26 12:02:29 2016 -0700 Add custom welcome page for upgrading users commit2ba9aedfe9
Author: Juan Tejada <juans.tejada@gmail.com> Date: Wed May 25 17:27:12 2016 -0700 Add styling to Subscription tab in prefs commit384433a338
Author: Ben Gotow <bengotow@gmail.com> Date: Wed May 25 16:21:18 2016 -0700 Add better style reset, more IdentityStore changes commitc4f9dfb4e4
Author: Ben Gotow <bengotow@gmail.com> Date: Wed May 25 15:29:41 2016 -0700 Add subscription tab commitbd4c25405a
Author: Ben Gotow <bengotow@gmail.com> Date: Wed May 25 14:18:40 2016 -0700 Point to billing-staging for now commit578e808bfc
Author: Ben Gotow <bengotow@gmail.com> Date: Wed May 25 13:30:13 2016 -0700 Rename account helpers > onboarding helpers commitdfea0a9861
Author: Ben Gotow <bengotow@gmail.com> Date: Wed May 25 13:26:46 2016 -0700 A few minor fixes commit7110217fd4
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 commitdc9ea45ca9
Author: Ben Gotow <bengotow@gmail.com> Date: Wed May 25 12:52:39 2016 -0700 Renaming and tweaks commit5ca4cd31ce
Author: Ben Gotow <bengotow@gmail.com> Date: Wed May 25 11:03:57 2016 -0700 Call success with response object as well commit45f14f9b00
Author: Ben Gotow <bengotow@gmail.com> Date: Tue May 24 18:26:38 2016 -0700 IMAP provider list, validation commitc6ca124e6e
Author: Ben Gotow <bengotow@gmail.com> Date: Sat May 21 11:14:44 2016 -0700 New onboarding screens commitdad918d926
Author: Ben Gotow <bengotow@gmail.com> Date: Thu May 19 16:37:31 2016 -0700 Remove resizing commitecb1a569e2
Author: Ben Gotow <bengotow@gmail.com> Date: Thu May 19 16:36:04 2016 -0700 Scrub packages page, unused code in onboarding pkg commit3e0a44156c
Author: Ben Gotow <bengotow@gmail.com> Date: Thu May 19 16:33:12 2016 -0700 Enable Templates and Translate by default commit0d218bc86f
Author: Ben Gotow <bengotow@gmail.com> Date: Thu May 19 16:30:47 2016 -0700 Rip out all invite-related code
|
@ -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());
|
||||
|
|
|
@ -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;
|
||||
|
|
BIN
internal_packages/onboarding/assets/app-screenshot@2x.png
Normal file
After Width: | Height: | Size: 589 KiB |
BIN
internal_packages/onboarding/assets/feature-activity@2x.png
Normal file
After Width: | Height: | Size: 59 KiB |
BIN
internal_packages/onboarding/assets/feature-composer@2x.png
Normal file
After Width: | Height: | Size: 71 KiB |
BIN
internal_packages/onboarding/assets/feature-people@2x.png
Normal file
After Width: | Height: | Size: 111 KiB |
BIN
internal_packages/onboarding/assets/feature-snooze@2x.png
Normal file
After Width: | Height: | Size: 59 KiB |
Before Width: | Height: | Size: 299 KiB After Width: | Height: | Size: 839 KiB |
|
@ -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
|
|
@ -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…
|
||||
</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, you’ll 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
|
|
@ -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
|
48
internal_packages/onboarding/lib/account-types.es6
Normal 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;
|
1346
internal_packages/onboarding/lib/common-provider-settings.json
Normal 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…
|
||||
</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;
|
34
internal_packages/onboarding/lib/form-error-message.jsx
Normal 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;
|
33
internal_packages/onboarding/lib/form-field.jsx
Normal 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;
|
|
@ -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
|
|
@ -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
|
34
internal_packages/onboarding/lib/main.es6
Normal 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() {
|
||||
|
||||
}
|
|
@ -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()
|
|
@ -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
|
16
internal_packages/onboarding/lib/onboarding-actions.es6
Normal 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;
|
159
internal_packages/onboarding/lib/onboarding-helpers.es6
Normal 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);
|
||||
}
|
82
internal_packages/onboarding/lib/onboarding-root.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
156
internal_packages/onboarding/lib/onboarding-store.es6
Normal 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();
|
44
internal_packages/onboarding/lib/page-account-choose.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
100
internal_packages/onboarding/lib/page-account-settings-imap.jsx
Normal 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);
|
79
internal_packages/onboarding/lib/page-account-settings.jsx
Normal 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);
|
152
internal_packages/onboarding/lib/page-authenticate.jsx
Normal 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 = ' '
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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()
|
|
@ -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
|
50
internal_packages/onboarding/lib/page-top-bar.jsx
Normal 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;
|
137
internal_packages/onboarding/lib/page-tutorial.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
47
internal_packages/onboarding/lib/page-welcome.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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…
|
||||
</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
|
|
@ -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 = "Let’s 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
|
|
@ -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()
|
187
internal_packages/onboarding/stylesheets/onboarding-reset.less
Normal 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%)));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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: ""
|
||||
|
|
|
@ -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', {}, {
|
||||
|
|
|
@ -7,7 +7,6 @@ import PreferencesAccountDetails from './preferences-account-details';
|
|||
|
||||
|
||||
class PreferencesAccounts extends React.Component {
|
||||
|
||||
static displayName = 'PreferencesAccounts';
|
||||
|
||||
constructor() {
|
||||
|
|
142
internal_packages/preferences/lib/tabs/preferences-identity.jsx
Normal 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} />
|
||||
{this.props.label}…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (this.props.img) {
|
||||
return (
|
||||
<div className="btn" onClick={this._onClick}>
|
||||
<RetinaImg name={this.props.img} mode={RetinaImg.Mode.ContentPreserve} />
|
||||
{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;
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) =>
|
||||
|
|
|
@ -152,6 +152,12 @@ class Actions
|
|||
###
|
||||
@clearDeveloperConsole: ActionScopeWindow
|
||||
|
||||
###
|
||||
Public: Manage the Nylas identity
|
||||
###
|
||||
@setNylasIdentity: ActionScopeWindow
|
||||
@logoutNylasIdentity: ActionScopeWindow
|
||||
|
||||
###
|
||||
Public: Remove the selected account
|
||||
|
||||
|
|
|
@ -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
|
@ -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;
|
|
@ -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) {
|
||||
|
|
108
src/flux/nylas-api-request.es6
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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) ->
|
||||
|
|
|
@ -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
|
||||
|
|
151
src/flux/stores/identity-store.es6
Normal 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()
|
|
@ -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: {
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
|
BIN
static/images/preferences/identity/ic-upgrade@2x.png
Normal file
After Width: | Height: | Size: 791 B |
After Width: | Height: | Size: 12 KiB |
After Width: | Height: | Size: 3.2 KiB |
After Width: | Height: | Size: 7.9 KiB |