From a258e3d881450e180cf9ca700716727adf534790 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Fri, 30 Sep 2016 12:43:40 +0100 Subject: [PATCH] Ghost.org OAuth support (#278) issue TryGhost/Ghost#7452, requires TryGhost/Ghost#7451 - use a `ghostOAuth` config flag to switch between the old-style per-install auth and centralized OAuth auth based on config provided by the server - add OAuth flows for: - setup - sign-in - sign-up - re-authenticate - add custom `oauth-ghost` authenticator to support our custom data structure - add test helpers to stub successful/failed oauth authentication - hide change password form if using OAuth (temporary - a way to change password via oauth provider will be added later) --- .../admin/app/authenticators/oauth2-ghost.js | 41 ++++++ .../app/components/modals/re-authenticate.js | 90 ++++++++---- ghost/admin/app/controllers/setup/two.js | 134 ++++++++++++------ ghost/admin/app/controllers/signin.js | 45 +----- ghost/admin/app/controllers/team/user.js | 5 + .../admin/app/mirage/config/authentication.js | 30 ++++ ghost/admin/app/routes/setup.js | 7 +- ghost/admin/app/routes/setup/two.js | 67 +++++++++ ghost/admin/app/routes/signin.js | 60 ++++++++ ghost/admin/app/routes/signup.js | 66 +++++++++ ghost/admin/app/services/config.js | 9 +- .../components/modals/re-authenticate.hbs | 18 ++- ghost/admin/app/templates/setup/two.hbs | 108 +++++++++----- ghost/admin/app/templates/signin.hbs | 34 +++-- ghost/admin/app/templates/signup.hbs | 77 ++++++---- ghost/admin/app/templates/team/user.hbs | 11 +- .../admin/app/torii-providers/ghost-oauth2.js | 33 +++++ ghost/admin/app/validators/new-user.js | 11 +- ghost/admin/app/validators/setup.js | 13 +- ghost/admin/config/environment.js | 4 + ghost/admin/package.json | 1 + ghost/admin/tests/acceptance/setup-test.js | 123 ++++++++++++++++ ghost/admin/tests/acceptance/signin-test.js | 54 +++++++ ghost/admin/tests/acceptance/signup-test.js | 113 ++++++++++++--- ghost/admin/tests/acceptance/team-test.js | 38 +++++ ghost/admin/tests/helpers/oauth.js | 39 +++++ ghost/admin/tests/index.html | 1 + .../components/gh-theme-table-test.js | 2 - 28 files changed, 1000 insertions(+), 234 deletions(-) create mode 100644 ghost/admin/app/authenticators/oauth2-ghost.js create mode 100644 ghost/admin/app/routes/setup/two.js create mode 100644 ghost/admin/app/torii-providers/ghost-oauth2.js create mode 100644 ghost/admin/tests/helpers/oauth.js diff --git a/ghost/admin/app/authenticators/oauth2-ghost.js b/ghost/admin/app/authenticators/oauth2-ghost.js new file mode 100644 index 0000000000..8a24b5b2ef --- /dev/null +++ b/ghost/admin/app/authenticators/oauth2-ghost.js @@ -0,0 +1,41 @@ +/* jscs:disable requireCamelCaseOrUpperCaseIdentifiers */ +import Oauth2Authenticator from './oauth2'; +import computed from 'ember-computed'; +import RSVP from 'rsvp'; +import run from 'ember-runloop'; +import {assign} from 'ember-platform'; +import {isEmpty} from 'ember-utils'; +import {wrap} from 'ember-array/utils'; + +export default Oauth2Authenticator.extend({ + serverTokenEndpoint: computed('ghostPaths.apiRoot', function () { + return `${this.get('ghostPaths.apiRoot')}/authentication/ghost`; + }), + + // TODO: all this is doing is changing the `data` structure, we should + // probably create our own token auth, maybe look at + // https://github.com/jpadilla/ember-simple-auth-token + authenticate(identification, password, scope = []) { + return new RSVP.Promise((resolve, reject) => { + // const data = { 'grant_type': 'password', username: identification, password }; + let data = identification; + let serverTokenEndpoint = this.get('serverTokenEndpoint'); + let scopesString = wrap(scope).join(' '); + if (!isEmpty(scopesString)) { + data.scope = scopesString; + } + this.makeRequest(serverTokenEndpoint, data).then((response) => { + run(() => { + let expiresAt = this._absolutizeExpirationTime(response.expires_in); + this._scheduleAccessTokenRefresh(response.expires_in, expiresAt, response.refresh_token); + if (!isEmpty(expiresAt)) { + response = assign(response, {'expires_at': expiresAt}); + } + resolve(response); + }); + }, (xhr) => { + run(null, reject, xhr.responseJSON || xhr.responseText); + }); + }); + } +}); diff --git a/ghost/admin/app/components/modals/re-authenticate.js b/ghost/admin/app/components/modals/re-authenticate.js index 51d91ddb24..cd282f4bfc 100644 --- a/ghost/admin/app/components/modals/re-authenticate.js +++ b/ghost/admin/app/components/modals/re-authenticate.js @@ -12,8 +12,10 @@ export default ModalComponent.extend(ValidationEngine, { submitting: false, authenticationError: null, + config: injectService(), notifications: injectService(), session: injectService(), + torii: injectService(), identification: computed('session.user.email', function () { return this.get('session.user.email'); @@ -35,35 +37,69 @@ export default ModalComponent.extend(ValidationEngine, { }); }, + _passwordConfirm() { + // Manually trigger events for input fields, ensuring legacy compatibility with + // browsers and password managers that don't send proper events on autofill + $('#login').find('input').trigger('change'); + + this.set('authenticationError', null); + + this.validate({property: 'signin'}).then(() => { + this._authenticate().then(() => { + this.get('notifications').closeAlerts(); + this.send('closeModal'); + }).catch((error) => { + if (error && error.errors) { + error.errors.forEach((err) => { + if (isVersionMismatchError(err)) { + return this.get('notifications').showAPIError(error); + } + err.message = htmlSafe(err.message); + }); + + this.get('errors').add('password', 'Incorrect password'); + this.get('hasValidated').pushObject('password'); + this.set('authenticationError', error.errors[0].message); + } + }); + }, () => { + this.get('hasValidated').pushObject('password'); + }); + }, + + _oauthConfirm() { + // TODO: remove duplication between signin/signup/re-auth + let authStrategy = 'authenticator:oauth2-ghost'; + + this.toggleProperty('submitting'); + this.set('authenticationError', ''); + + this.get('torii') + .open('ghost-oauth2', {type: 'signin'}) + .then((authentication) => { + this.get('session').set('skipAuthSuccessHandler', true); + + this.get('session').authenticate(authStrategy, authentication).finally(() => { + this.get('session').set('skipAuthSuccessHandler', undefined); + + this.toggleProperty('submitting'); + this.get('notifications').closeAlerts(); + this.send('closeModal'); + }); + }) + .catch(() => { + this.toggleProperty('submitting'); + this.set('authenticationError', 'Authentication with Ghost.org denied or failed'); + }); + }, + actions: { confirm() { - // Manually trigger events for input fields, ensuring legacy compatibility with - // browsers and password managers that don't send proper events on autofill - $('#login').find('input').trigger('change'); - - this.set('authenticationError', null); - - this.validate({property: 'signin'}).then(() => { - this._authenticate().then(() => { - this.get('notifications').closeAlerts('post.save'); - this.send('closeModal'); - }).catch((error) => { - if (error && error.errors) { - error.errors.forEach((err) => { - if (isVersionMismatchError(err)) { - return this.get('notifications').showAPIError(error); - } - err.message = htmlSafe(err.message); - }); - - this.get('errors').add('password', 'Incorrect password'); - this.get('hasValidated').pushObject('password'); - this.set('authenticationError', error.errors[0].message); - } - }); - }, () => { - this.get('hasValidated').pushObject('password'); - }); + if (this.get('config.ghostOAuth')) { + return this._oauthConfirm(); + } else { + return this._passwordConfirm(); + } } } }); diff --git a/ghost/admin/app/controllers/setup/two.js b/ghost/admin/app/controllers/setup/two.js index 6210a37ef7..569b74ca97 100644 --- a/ghost/admin/app/controllers/setup/two.js +++ b/ghost/admin/app/controllers/setup/two.js @@ -89,6 +89,90 @@ export default Controller.extend(ValidationEngine, { } }, + _passwordSetup() { + let setupProperties = ['blogTitle', 'name', 'email', 'password']; + let data = this.getProperties(setupProperties); + let config = this.get('config'); + let method = this.get('blogCreated') ? 'put' : 'post'; + + this.toggleProperty('submitting'); + this.set('flowErrors', ''); + + this.get('hasValidated').addObjects(setupProperties); + this.validate().then(() => { + let authUrl = this.get('ghostPaths.url').api('authentication', 'setup'); + this.get('ajax')[method](authUrl, { + data: { + setup: [{ + name: data.name, + email: data.email, + password: data.password, + blogTitle: data.blogTitle + }] + } + }).then((result) => { + config.set('blogTitle', data.blogTitle); + + // don't try to login again if we are already logged in + if (this.get('session.isAuthenticated')) { + return this.afterAuthentication(result); + } + + // Don't call the success handler, otherwise we will be redirected to admin + this.set('session.skipAuthSuccessHandler', true); + this.get('session').authenticate('authenticator:oauth2', this.get('email'), this.get('password')).then(() => { + this.set('blogCreated', true); + return this.afterAuthentication(result); + }).catch((error) => { + this._handleAuthenticationError(error); + }).finally(() => { + this.set('session.skipAuthSuccessHandler', undefined); + }); + }).catch((error) => { + this._handleSaveError(error); + }); + }).catch(() => { + this.toggleProperty('submitting'); + this.set('flowErrors', 'Please fill out the form to setup your blog.'); + }); + }, + + // TODO: for OAuth ghost is in the "setup completed" step as soon + // as a user has been authenticated so we need to use the standard settings + // update to set the blog title before redirecting + _oauthSetup() { + let blogTitle = this.get('blogTitle'); + let config = this.get('config'); + + this.get('hasValidated').addObjects(['blogTitle', 'session']); + + return this.validate().then(() => { + this.store.queryRecord('setting', {type: 'blog,theme,private'}) + .then((settings) => { + settings.set('title', blogTitle); + + return settings.save() + .then((settings) => { + // update the config so that the blog title shown in + // the nav bar is also updated + config.set('blogTitle', settings.get('title')); + + // this.blogCreated is used by step 3 to check if step 2 + // has been completed + this.set('blogCreated', true); + return this.afterAuthentication(settings); + }) + .catch((error) => { + this._handleSaveError(error); + }); + }) + .finally(() => { + this.toggleProperty('submitting'); + this.set('session.skipAuthSuccessHandler', undefined); + }); + }); + }, + actions: { preValidate(model) { // Only triggers validation if a value has been entered, preventing empty errors on focusOut @@ -98,51 +182,11 @@ export default Controller.extend(ValidationEngine, { }, setup() { - let setupProperties = ['blogTitle', 'name', 'email', 'password']; - let data = this.getProperties(setupProperties); - let config = this.get('config'); - let method = this.get('blogCreated') ? 'put' : 'post'; - - this.toggleProperty('submitting'); - this.set('flowErrors', ''); - - this.get('hasValidated').addObjects(setupProperties); - this.validate().then(() => { - let authUrl = this.get('ghostPaths.url').api('authentication', 'setup'); - this.get('ajax')[method](authUrl, { - data: { - setup: [{ - name: data.name, - email: data.email, - password: data.password, - blogTitle: data.blogTitle - }] - } - }).then((result) => { - config.set('blogTitle', data.blogTitle); - - // don't try to login again if we are already logged in - if (this.get('session.isAuthenticated')) { - return this.afterAuthentication(result); - } - - // Don't call the success handler, otherwise we will be redirected to admin - this.set('session.skipAuthSuccessHandler', true); - this.get('session').authenticate('authenticator:oauth2', this.get('email'), this.get('password')).then(() => { - this.set('blogCreated', true); - return this.afterAuthentication(result); - }).catch((error) => { - this._handleAuthenticationError(error); - }).finally(() => { - this.set('session.skipAuthSuccessHandler', undefined); - }); - }).catch((error) => { - this._handleSaveError(error); - }); - }).catch(() => { - this.toggleProperty('submitting'); - this.set('flowErrors', 'Please fill out the form to setup your blog.'); - }); + if (this.get('config.ghostOAuth')) { + return this._oauthSetup(); + } else { + return this._passwordSetup(); + } }, setImage(image) { diff --git a/ghost/admin/app/controllers/signin.js b/ghost/admin/app/controllers/signin.js index b2932d95b4..68f9eb2f30 100644 --- a/ghost/admin/app/controllers/signin.js +++ b/ghost/admin/app/controllers/signin.js @@ -5,7 +5,6 @@ import injectController from 'ember-controller/inject'; import {isEmberArray} from 'ember-array/utils'; import { - VersionMismatchError, isVersionMismatchError } from 'ghost-admin/services/ajax'; import ValidationEngine from 'ghost-admin/mixins/validation-engine'; @@ -15,55 +14,23 @@ export default Controller.extend(ValidationEngine, { loggingIn: false, authProperties: ['identification', 'password'], + ajax: injectService(), + application: injectController(), + config: injectService(), ghostPaths: injectService(), notifications: injectService(), session: injectService(), - application: injectController(), - ajax: injectService(), + flowErrors: '', // ValidationEngine settings validationType: 'signin', actions: { - authenticate() { + validateAndAuthenticate() { let model = this.get('model'); let authStrategy = 'authenticator:oauth2'; - // Authentication transitions to posts.index, we can leave spinner running unless there is an error - this.get('session').authenticate(authStrategy, model.get('identification'), model.get('password')).catch((error) => { - this.toggleProperty('loggingIn'); - - if (error && error.errors) { - // we don't get back an ember-data/ember-ajax error object - // back so we need to pass in a null status in order to - // test against the payload - if (isVersionMismatchError(null, error)) { - let versionMismatchError = new VersionMismatchError(error); - return this.get('notifications').showAPIError(versionMismatchError); - } - - error.errors.forEach((err) => { - err.message = err.message.htmlSafe(); - }); - - this.set('flowErrors', error.errors[0].message.string); - - if (error.errors[0].message.string.match(/user with that email/)) { - this.get('model.errors').add('identification', ''); - } - - if (error.errors[0].message.string.match(/password is incorrect/)) { - this.get('model.errors').add('password', ''); - } - } else { - // Connection errors don't return proper status message, only req.body - this.get('notifications').showAlert('There was a problem on the server.', {type: 'error', key: 'session.authenticate.failed'}); - } - }); - }, - - validateAndAuthenticate() { this.set('flowErrors', ''); // Manually trigger events for input fields, ensuring legacy compatibility with // browsers and password managers that don't send proper events on autofill @@ -73,7 +40,7 @@ export default Controller.extend(ValidationEngine, { this.get('hasValidated').addObjects(this.authProperties); this.validate({property: 'signin'}).then(() => { this.toggleProperty('loggingIn'); - this.send('authenticate'); + this.send('authenticate', authStrategy, [model.get('identification'), model.get('password')]); }).catch(() => { this.set('flowErrors', 'Please fill out the form to sign in.'); }); diff --git a/ghost/admin/app/controllers/team/user.js b/ghost/admin/app/controllers/team/user.js index d705620d44..21e868738e 100644 --- a/ghost/admin/app/controllers/team/user.js +++ b/ghost/admin/app/controllers/team/user.js @@ -19,6 +19,7 @@ export default Controller.extend({ _scratchTwitter: null, ajax: injectService(), + config: injectService(), dropdown: injectService(), ghostPaths: injectService(), notifications: injectService(), @@ -50,6 +51,10 @@ export default Controller.extend({ } }), + canChangePassword: computed('config.ghostOAuth', 'isAdminUserOnOwnerProfile', function () { + return !this.get('config.ghostOAuth') && !this.get('isAdminUserOnOwnerProfile'); + }), + // duplicated in gh-user-active -- find a better home and consolidate? userDefault: computed('ghostPaths', function () { return `${this.get('ghostPaths.subdir')}/ghost/img/user-image.png`; diff --git a/ghost/admin/app/mirage/config/authentication.js b/ghost/admin/app/mirage/config/authentication.js index 0bfd625b9c..8b3b89f049 100644 --- a/ghost/admin/app/mirage/config/authentication.js +++ b/ghost/admin/app/mirage/config/authentication.js @@ -36,6 +36,21 @@ export default function mockAuthentication(server) { } }); + server.get('/authentication/invitation/', function (db, request) { + let {email} = request.queryParams; + let [invite] = db.invites.where({email}); + let user = db.users.find(invite.created_by); + let valid = !!invite; + let invitedBy = user && user.name; + + return { + invitation: [{ + valid, + invitedBy + }] + }; + }); + /* Setup ---------------------------------------------------------------- */ server.post('/authentication/setup', function (db, request) { @@ -70,4 +85,19 @@ export default function mockAuthentication(server) { ] }; }); + + /* OAuth ---------------------------------------------------------------- */ + + server.post('/authentication/ghost', function (db) { + if (!db.users.length) { + let [role] = db.roles.where({name: 'Owner'}); + server.create('user', {email: 'oauthtest@example.com', roles: [role]}); + } + + return { + access_token: '5JhTdKI7PpoZv4ROsFoERc6wCHALKFH5jxozwOOAErmUzWrFNARuH1q01TYTKeZkPW7FmV5MJ2fU00pg9sm4jtH3Z1LjCf8D6nNqLYCfFb2YEKyuvG7zHj4jZqSYVodN2YTCkcHv6k8oJ54QXzNTLIDMlCevkOebm5OjxGiJpafMxncm043q9u1QhdU9eee3zouGRMVVp8zkKVoo5zlGMi3zvS2XDpx7xsfk8hKHpUgd7EDDQxmMueifWv7hv6n', + expires_in: 3600, + refresh_token: 'XP13eDjwV5mxOcrq1jkIY9idhdvN3R1Br5vxYpYIub2P5Hdc8pdWMOGmwFyoUshiEB62JWHTl8H1kACJR18Z8aMXbnk5orG28br2kmVgtVZKqOSoiiWrQoeKTqrRV0t7ua8uY5HdDUaKpnYKyOdpagsSPn3WEj8op4vHctGL3svOWOjZhq6F2XeVPMR7YsbiwBE8fjT3VhTB3KRlBtWZd1rE0Qo2EtSplWyjGKv1liAEiL0ndQoLeeSOCH4rTP7' + }; + }); } diff --git a/ghost/admin/app/routes/setup.js b/ghost/admin/app/routes/setup.js index 9ba7bb6b49..d13bb76b47 100644 --- a/ghost/admin/app/routes/setup.js +++ b/ghost/admin/app/routes/setup.js @@ -11,20 +11,23 @@ export default Route.extend(styleBody, { ghostPaths: injectService(), session: injectService(), ajax: injectService(), + config: injectService(), // use the beforeModel hook to check to see whether or not setup has been // previously completed. If it has, stop the transition into the setup page. beforeModel() { this._super(...arguments); - if (this.get('session.isAuthenticated')) { + // with OAuth auth users are authenticated on step 2 so we + // can't use the session.isAuthenticated shortcut + if (!this.get('config.ghostOAuth') && this.get('session.isAuthenticated')) { this.transitionTo(Configuration.routeIfAlreadyAuthenticated); return; } let authUrl = this.get('ghostPaths.url').api('authentication', 'setup'); - // If user is not logged in, check the state of the setup process via the API + // check the state of the setup process via the API return this.get('ajax').request(authUrl) .then((result) => { let [setup] = result.setup; diff --git a/ghost/admin/app/routes/setup/two.js b/ghost/admin/app/routes/setup/two.js new file mode 100644 index 0000000000..79e9b84bae --- /dev/null +++ b/ghost/admin/app/routes/setup/two.js @@ -0,0 +1,67 @@ +import Route from 'ember-route'; +import injectService from 'ember-service/inject'; +import { + VersionMismatchError, + isVersionMismatchError +} from 'ghost-admin/services/ajax'; + +export default Route.extend({ + + session: injectService(), + notifications: injectService(), + + actions: { + // TODO: reduce duplication with setup/signin/signup routes + authenticateWithGhostOrg() { + let authStrategy = 'authenticator:oauth2-ghost'; + + this.toggleProperty('controller.loggingIn'); + this.set('controller.flowErrors', ''); + + this.get('torii') + .open('ghost-oauth2', {type: 'setup'}) + .then((authentication) => { + this.send('authenticate', authStrategy, [authentication]); + }) + .catch(() => { + this.toggleProperty('controller.loggingIn'); + this.set('controller.flowErrors', 'Authentication with Ghost.org denied or failed'); + }); + }, + + authenticate(strategy, authentication) { + // we don't want to redirect after sign-in during setup + this.set('session.skipAuthSuccessHandler', true); + + // Authentication transitions to posts.index, we can leave spinner running unless there is an error + this.get('session') + .authenticate(strategy, ...authentication) + .then(() => { + this.get('controller.errors').remove('session'); + }) + .catch((error) => { + if (error && error.errors) { + // we don't get back an ember-data/ember-ajax error object + // back so we need to pass in a null status in order to + // test against the payload + if (isVersionMismatchError(null, error)) { + let versionMismatchError = new VersionMismatchError(error); + return this.get('notifications').showAPIError(versionMismatchError); + } + + error.errors.forEach((err) => { + err.message = err.message.htmlSafe(); + }); + + this.set('controller.flowErrors', error.errors[0].message.string); + } else { + // Connection errors don't return proper status message, only req.body + this.get('notifications').showAlert('There was a problem on the server.', {type: 'error', key: 'session.authenticate.failed'}); + } + }) + .finally(() => { + this.toggleProperty('controller.loggingIn'); + }); + } + } +}); diff --git a/ghost/admin/app/routes/signin.js b/ghost/admin/app/routes/signin.js index 18b3d88e38..ff948de1c1 100644 --- a/ghost/admin/app/routes/signin.js +++ b/ghost/admin/app/routes/signin.js @@ -4,6 +4,10 @@ import EmberObject from 'ember-object'; import styleBody from 'ghost-admin/mixins/style-body'; import Configuration from 'ember-simple-auth/configuration'; import DS from 'ember-data'; +import { + VersionMismatchError, + isVersionMismatchError +} from 'ghost-admin/services/ajax'; const {Errors} = DS; @@ -13,6 +17,7 @@ export default Route.extend(styleBody, { classNames: ['ghost-login'], session: injectService(), + notifications: injectService(), beforeModel() { this._super(...arguments); @@ -39,5 +44,60 @@ export default Route.extend(styleBody, { // clear the properties that hold the credentials when we're no longer on the signin screen controller.set('model.identification', ''); controller.set('model.password', ''); + }, + + actions: { + authenticateWithGhostOrg() { + let authStrategy = 'authenticator:oauth2-ghost'; + + this.toggleProperty('controller.loggingIn'); + this.set('controller.flowErrors', ''); + + this.get('torii') + .open('ghost-oauth2', {type: 'signin'}) + .then((authentication) => { + this.send('authenticate', authStrategy, [authentication]); + }) + .catch(() => { + this.toggleProperty('controller.loggingIn'); + this.set('controller.flowErrors', 'Authentication with Ghost.org denied or failed'); + }); + }, + + authenticate(strategy, authentication) { + // Authentication transitions to posts.index, we can leave spinner running unless there is an error + this.get('session') + .authenticate(strategy, ...authentication) + .catch((error) => { + this.toggleProperty('controller.loggingIn'); + + if (error && error.errors) { + // we don't get back an ember-data/ember-ajax error object + // back so we need to pass in a null status in order to + // test against the payload + if (isVersionMismatchError(null, error)) { + let versionMismatchError = new VersionMismatchError(error); + return this.get('notifications').showAPIError(versionMismatchError); + } + + error.errors.forEach((err) => { + err.message = err.message.htmlSafe(); + }); + + this.set('controller.flowErrors', error.errors[0].message.string); + + if (error.errors[0].message.string.match(/user with that email/)) { + this.get('controller.model.errors').add('identification', ''); + } + + if (error.errors[0].message.string.match(/password is incorrect/)) { + this.get('controller.model.errors').add('password', ''); + } + } else { + // Connection errors don't return proper status message, only req.body + this.get('notifications').showAlert('There was a problem on the server.', {type: 'error', key: 'session.authenticate.failed'}); + } + }); + } } }); diff --git a/ghost/admin/app/routes/signup.js b/ghost/admin/app/routes/signup.js index 2b221413ac..0f85e40be1 100644 --- a/ghost/admin/app/routes/signup.js +++ b/ghost/admin/app/routes/signup.js @@ -2,6 +2,11 @@ import Route from 'ember-route'; import RSVP from 'rsvp'; import injectService from 'ember-service/inject'; import EmberObject from 'ember-object'; +import {assign} from 'ember-platform'; +import { + VersionMismatchError, + isVersionMismatchError +} from 'ghost-admin/services/ajax'; import DS from 'ember-data'; import Configuration from 'ember-simple-auth/configuration'; @@ -61,6 +66,8 @@ export default Route.extend(styleBody, { return resolve(this.transitionTo('signin')); } + model.set('invitedBy', response.invitation[0].invitedBy); + resolve(model); }).catch(() => { resolve(model); @@ -73,5 +80,64 @@ export default Route.extend(styleBody, { // clear the properties that hold the sensitive data from the controller this.controllerFor('signup').setProperties({email: '', password: '', token: ''}); + }, + + actions: { + authenticateWithGhostOrg() { + let authStrategy = 'authenticator:oauth2-ghost'; + let inviteToken = this.get('controller.model.token'); + let email = this.get('controller.model.email'); + + this.toggleProperty('controller.loggingIn'); + this.set('controller.flowErrors', ''); + + this.get('torii') + .open('ghost-oauth2', {email, type: 'invite'}) + .then((authentication) => { + let _authentication = assign({}, authentication, {inviteToken}); + this.send('authenticate', authStrategy, [_authentication]); + }) + .catch(() => { + this.toggleProperty('controller.loggingIn'); + this.set('controller.flowErrors', 'Authentication with Ghost.org denied or failed'); + }); + }, + + // TODO: this is duplicated with the signin route - maybe extract into a mixin? + authenticate(strategy, authentication) { + // Authentication transitions to posts.index, we can leave spinner running unless there is an error + this.get('session') + .authenticate(strategy, ...authentication) + .catch((error) => { + this.toggleProperty('controller.loggingIn'); + + if (error && error.errors) { + // we don't get back an ember-data/ember-ajax error object + // back so we need to pass in a null status in order to + // test against the payload + if (isVersionMismatchError(null, error)) { + let versionMismatchError = new VersionMismatchError(error); + return this.get('notifications').showAPIError(versionMismatchError); + } + + error.errors.forEach((err) => { + err.message = err.message.htmlSafe(); + }); + + this.set('controller.flowErrors', error.errors[0].message.string); + + if (error.errors[0].message.string.match(/user with that email/)) { + this.get('controller.model.errors').add('identification', ''); + } + + if (error.errors[0].message.string.match(/password is incorrect/)) { + this.get('controller.model.errors').add('password', ''); + } + } else { + // Connection errors don't return proper status message, only req.body + this.get('notifications').showAlert('There was a problem on the server.', {type: 'error', key: 'session.authenticate.failed'}); + } + }); + } } }); diff --git a/ghost/admin/app/services/config.js b/ghost/admin/app/services/config.js index c001f9bb0a..fd4bd2fd5e 100644 --- a/ghost/admin/app/services/config.js +++ b/ghost/admin/app/services/config.js @@ -3,8 +3,9 @@ import Ember from 'ember'; import Service from 'ember-service'; import computed from 'ember-computed'; import injectService from 'ember-service/inject'; +import {isBlank} from 'ember-utils'; -// ember-cli-shims doesn't export _ProxyMixin +// ember-cli-shims doesn't export _ProxyMixin ot testing const {_ProxyMixin} = Ember; const {isNumeric} = $; @@ -47,7 +48,7 @@ export default Service.extend(_ProxyMixin, { return config; }), - availableTimezones: computed(function() { + availableTimezones: computed(function () { let timezonesUrl = this.get('ghostPaths.url').api('configuration', 'timezones'); return this.get('ajax').request(timezonesUrl).then((configTimezones) => { @@ -57,5 +58,9 @@ export default Service.extend(_ProxyMixin, { return timezonesObj; }); + }), + + ghostOAuth: computed('ghostAuthId', function () { + return !isBlank(this.get('ghostAuthId')); }) }); diff --git a/ghost/admin/app/templates/components/modals/re-authenticate.hbs b/ghost/admin/app/templates/components/modals/re-authenticate.hbs index 030b34f504..1a84d0804d 100644 --- a/ghost/admin/app/templates/components/modals/re-authenticate.hbs +++ b/ghost/admin/app/templates/components/modals/re-authenticate.hbs @@ -4,12 +4,18 @@