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)
This commit is contained in:
Kevin Ansfield 2016-09-30 12:43:40 +01:00 committed by Katharina Irrgang
parent 7385c26b71
commit 0a163d7333
28 changed files with 1000 additions and 234 deletions

View File

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

View File

@ -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();
}
}
}
});

View File

@ -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) {

View File

@ -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.');
});

View File

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

View File

@ -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'
};
});
}

View File

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

67
app/routes/setup/two.js Normal file
View File

@ -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');
});
}
}
});

View File

@ -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'});
}
});
}
}
});

View File

@ -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'});
}
});
}
}
});

View File

@ -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'));
})
});

View File

@ -4,12 +4,18 @@
<a class="close icon-x" href="" title="Close" {{action "closeModal"}}><span class="hidden">Close</span></a>
<div class="modal-body {{if authenticationError 'error'}}">
<form id="login" class="login-form" method="post" novalidate="novalidate" {{action "confirm" on="submit"}}>
{{#gh-validation-status-container class="password-wrap" errors=errors property="password" hasValidated=hasValidated}}
{{gh-input password class="password" type="password" placeholder="Password" name="password" update=(action (mut password))}}
{{/gh-validation-status-container}}
{{#gh-spin-button class="btn btn-blue" type="submit" submitting=submitting}}Log in{{/gh-spin-button}}
</form>
{{#if config.ghostOAuth}}
{{#gh-spin-button class="login btn btn-blue btn-block" type="submit" action="confirm" tabindex="3" submitting=loggingIn autoWidth="false"}}Sign in with Ghost{{/gh-spin-button}}
{{else}}
<form id="login" class="login-form" method="post" novalidate="novalidate" {{action "confirm" on="submit"}}>
{{#gh-validation-status-container class="password-wrap" errors=errors property="password" hasValidated=hasValidated}}
{{gh-input password class="password" type="password" placeholder="Password" name="password" update=(action (mut password))}}
{{/gh-validation-status-container}}
{{#gh-spin-button class="btn btn-blue" type="submit" submitting=submitting}}Log in{{/gh-spin-button}}
</form>
{{/if}}
{{#if authenticationError}}
<p class="response">{{authenticationError}}</p>
{{/if}}

View File

@ -1,44 +1,76 @@
<header>
<h1>Create your account</h1>
</header>
{{#if config.ghostOAuth}}
<header>
<h1>Setup your blog</h1>
</header>
<form id="setup" class="gh-flow-create">
{{!-- Horrible hack to prevent Chrome from incorrectly auto-filling inputs --}}
<input style="display:none;" type="text" name="fakeusernameremembered"/>
<input style="display:none;" type="password" name="fakepasswordremembered"/>
<form id="setup" class="gh-flow-create" {{action "setup" on="submit"}}>
{{#gh-form-group errors=errors hasValidated=hasValidated property="session"}}
{{#gh-spin-button class="login btn btn-blue btn-block" type="button" action="authenticateWithGhostOrg" tabindex="3" submitting=loggingIn autoWidth="false"}}
{{#if session.isAuthenticated}}
Connected: {{session.user.email}}
{{else}}
Sign in with Ghost
{{/if}}
{{/gh-spin-button}}
{{gh-error-message errors=errors property="session"}}
{{/gh-form-group}}
{{gh-profile-image fileStorage=config.fileStorage email=email setImage="setImage"}}
{{#gh-form-group errors=errors hasValidated=hasValidated property="email"}}
<label for="email-address">Email address</label>
<span class="input-icon icon-mail">
{{gh-trim-focus-input email tabindex="1" type="email" name="email" placeholder="Eg. john@example.com" autocorrect="off" focusOut=(action "preValidate" "email") update=(action (mut email))}}
</span>
{{gh-error-message errors=errors property="email"}}
{{/gh-form-group}}
{{#gh-form-group errors=errors hasValidated=hasValidated property="name"}}
<label for="full-name">Full name</label>
<span class="input-icon icon-user">
{{gh-input name tabindex="2" type="text" name="name" placeholder="Eg. John H. Watson" autocorrect="off" focusOut=(action "preValidate" "name") update=(action (mut name))}}
</span>
{{gh-error-message errors=errors property="name"}}
{{/gh-form-group}}
{{#gh-form-group errors=errors hasValidated=hasValidated property="password"}}
<label for="password">Password</label>
<span class="input-icon icon-lock">
{{gh-input password tabindex="3" type="password" name="password" placeholder="At least 8 characters" autocorrect="off" focusOut=(action "preValidate" "password") update=(action (mut password))}}
</span>
{{gh-error-message errors=errors property="password"}}
{{/gh-form-group}}
{{#gh-form-group errors=errors hasValidated=hasValidated property="blogTitle"}}
<label for="blog-title">Blog title</label>
<span class="input-icon icon-content">
{{gh-input blogTitle tabindex="4" type="text" name="blog-title" placeholder="Eg. The Daily Awesome" autocorrect="off" focusOut=(action "preValidate" "blogTitle") update=(action (mut blogTitle))}}
</span>
{{gh-error-message errors=errors property="blogTitle"}}
{{/gh-form-group}}
{{#gh-spin-button type="submit" tabindex="5" class="btn btn-green btn-lg btn-block" action="setup" submitting=submitting autoWidth="false"}}
{{#gh-form-group errors=errors hasValidated=hasValidated property="blogTitle"}}
<label for="blog-title">Blog title</label>
<span class="input-icon icon-content">
{{gh-input blogTitle tabindex="4" type="text" name="blog-title" placeholder="Eg. The Daily Awesome" autocorrect="off" focusOut=(action "preValidate" "blogTitle") update=(action (mut blogTitle)) onenter=(action "setup")}}
</span>
{{gh-error-message errors=errors property="blogTitle"}}
{{/gh-form-group}}
</form>
{{#gh-spin-button type="submit" tabindex="5" class="btn btn-green btn-lg btn-block" action=(action 'setup') disabled=submitDisabled submitting=submitting autoWidth="false"}}
Last step: Invite your team <i class="icon-chevron"></i>
{{/gh-spin-button}}
</form>
{{else}}
<header>
<h1>Create your account</h1>
</header>
<form id="setup" class="gh-flow-create">
{{!-- Horrible hack to prevent Chrome from incorrectly auto-filling inputs --}}
<input style="display:none;" type="text" name="fakeusernameremembered"/>
<input style="display:none;" type="password" name="fakepasswordremembered"/>
{{gh-profile-image fileStorage=config.fileStorage email=email setImage="setImage"}}
{{#gh-form-group errors=errors hasValidated=hasValidated property="email"}}
<label for="email-address">Email address</label>
<span class="input-icon icon-mail">
{{gh-trim-focus-input email tabindex="1" type="email" name="email" placeholder="Eg. john@example.com" autocorrect="off" focusOut=(action "preValidate" "email") update=(action (mut email))}}
</span>
{{gh-error-message errors=errors property="email"}}
{{/gh-form-group}}
{{#gh-form-group errors=errors hasValidated=hasValidated property="name"}}
<label for="full-name">Full name</label>
<span class="input-icon icon-user">
{{gh-input name tabindex="2" type="text" name="name" placeholder="Eg. John H. Watson" autocorrect="off" focusOut=(action "preValidate" "name") update=(action (mut name))}}
</span>
{{gh-error-message errors=errors property="name"}}
{{/gh-form-group}}
{{#gh-form-group errors=errors hasValidated=hasValidated property="password"}}
<label for="password">Password</label>
<span class="input-icon icon-lock">
{{gh-input password tabindex="3" type="password" name="password" placeholder="At least 8 characters" autocorrect="off" focusOut=(action "preValidate" "password") update=(action (mut password))}}
</span>
{{gh-error-message errors=errors property="password"}}
{{/gh-form-group}}
{{#gh-form-group errors=errors hasValidated=hasValidated property="blogTitle"}}
<label for="blog-title">Blog title</label>
<span class="input-icon icon-content">
{{gh-input blogTitle tabindex="4" type="text" name="blog-title" placeholder="Eg. The Daily Awesome" autocorrect="off" focusOut=(action "preValidate" "blogTitle") update=(action (mut blogTitle))}}
</span>
{{gh-error-message errors=errors property="blogTitle"}}
{{/gh-form-group}}
{{#gh-spin-button type="submit" tabindex="5" class="btn btn-green btn-lg btn-block" action="setup" submitting=submitting autoWidth="false"}}
Last step: Invite your team <i class="icon-chevron"></i>
{{/gh-spin-button}}
</form>
{{/if}}
<p class="main-error">{{{flowErrors}}}</p>

View File

@ -1,19 +1,29 @@
<div class="gh-flow">
<div class="gh-flow-content-wrap">
<section class="gh-flow-content">
{{#if config.ghostOAuth}}
<header>
<h1>{{config.blogTitle}}</h1>
</header>
{{/if}}
<form id="login" class="gh-signin" method="post" novalidate="novalidate">
{{#gh-form-group errors=model.errors hasValidated=hasValidated property="identification"}}
<span class="input-icon icon-mail">
{{gh-trim-focus-input model.identification class="email" type="email" placeholder="Email Address" name="identification" autocapitalize="off" autocorrect="off" tabindex="1" focusOut=(action "validate" "identification") update=(action (mut model.identification))}}
</span>
{{/gh-form-group}}
{{#gh-form-group errors=model.errors hasValidated=hasValidated property="password"}}
<span class="input-icon icon-lock forgotten-wrap">
{{gh-input model.password class="password" type="password" placeholder="Password" name="password" tabindex="2" autocorrect="off" update=(action (mut model.password))}}
{{#gh-spin-button class="forgotten-link btn btn-link" type="button" action="forgotten" tabindex="4" submitting=submitting autoWidth="true"}}Forgot?{{/gh-spin-button}}
</span>
{{/gh-form-group}}
{{#gh-spin-button class="login btn btn-blue btn-block" type="submit" action="validateAndAuthenticate" tabindex="3" submitting=loggingIn autoWidth="false"}}Sign in{{/gh-spin-button}}
{{#if config.ghostOAuth}}
{{#gh-spin-button class="login btn btn-blue btn-block" type="submit" action="authenticateWithGhostOrg" tabindex="3" submitting=loggingIn autoWidth="false"}}Sign in with Ghost{{/gh-spin-button}}
{{else}}
{{#gh-form-group errors=model.errors hasValidated=hasValidated property="identification"}}
<span class="input-icon icon-mail">
{{gh-trim-focus-input model.identification class="email" type="email" placeholder="Email Address" name="identification" autocapitalize="off" autocorrect="off" tabindex="1" focusOut=(action "validate" "identification") update=(action (mut model.identification))}}
</span>
{{/gh-form-group}}
{{#gh-form-group errors=model.errors hasValidated=hasValidated property="password"}}
<span class="input-icon icon-lock forgotten-wrap">
{{gh-input model.password class="password" type="password" placeholder="Password" name="password" tabindex="2" autocorrect="off" update=(action (mut model.password))}}
{{#gh-spin-button class="forgotten-link btn btn-link" type="button" action="forgotten" tabindex="4" submitting=submitting autoWidth="true"}}Forgot?{{/gh-spin-button}}
</span>
{{/gh-form-group}}
{{#gh-spin-button class="login btn btn-blue btn-block" type="submit" action="validateAndAuthenticate" tabindex="3" submitting=loggingIn autoWidth="false"}}Sign in{{/gh-spin-button}}
{{/if}}
</form>
<p class="main-error">{{{flowErrors}}}</p>

View File

@ -2,42 +2,59 @@
<div class="gh-flow-content-wrap">
<section class="gh-flow-content">
<header>
<h1>Create your account</h1>
</header>
{{#if config.ghostOAuth}}
<header>
<h1>{{config.blogTitle}}</h1>
<p>
{{!-- TODO: show invite creator's name/email --}}
Accept your invite from <strong>{{model.invitedBy}}</strong>
</p>
</header>
<form id="signup" class="gh-flow-create" method="post" novalidate="novalidate">
{{!-- Hack to stop Chrome's broken auto-fills --}}
<input style="display:none;" type="text" name="fakeusernameremembered"/>
<input style="display:none;" type="password" name="fakepasswordremembered"/>
<form id="signup" class="gh-signin" method="post" novalidate="novalidate">
{{#gh-spin-button class="login btn btn-blue btn-block" type="submit" action="authenticateWithGhostOrg" tabindex="3" submitting=loggingIn autoWidth="false"}}
Sign in with Ghost to accept
{{/gh-spin-button}}
</form>
{{else}}
<header>
<h1>Create your account</h1>
</header>
{{gh-profile-image fileStorage=config.fileStorage email=model.email setImage="setImage"}}
<form id="signup" class="gh-flow-create" method="post" novalidate="novalidate">
{{!-- Hack to stop Chrome's broken auto-fills --}}
<input style="display:none;" type="text" name="fakeusernameremembered"/>
<input style="display:none;" type="password" name="fakepasswordremembered"/>
{{#gh-form-group}}
<label for="email-address">Email address</label>
<span class="input-icon icon-mail">
{{gh-input model.email type="email" name="email" placeholder="Eg. john@example.com" disabled="disabled" autocorrect="off"}}
</span>
{{/gh-form-group}}
{{gh-profile-image fileStorage=config.fileStorage email=model.email setImage="setImage"}}
{{#gh-form-group errors=model.errors hasValidated=hasValidated property="name"}}
<label for="full-name">Full name</label>
<span class="input-icon icon-user">
{{gh-trim-focus-input model.name tabindex="1" type="text" name="name" placeholder="Eg. John H. Watson" onenter=(action "signup") autocorrect="off" focusOut=(action "validate" "name") update=(action (mut model.name))}}
</span>
{{gh-error-message errors=model.errors property="name"}}
{{/gh-form-group}}
{{#gh-form-group}}
<label for="email-address">Email address</label>
<span class="input-icon icon-mail">
{{gh-input model.email type="email" name="email" placeholder="Eg. john@example.com" disabled="disabled" autocorrect="off"}}
</span>
{{/gh-form-group}}
{{#gh-form-group errors=model.errors hasValidated=hasValidated property="password"}}
<label for="password">Password</label>
<span class="input-icon icon-lock">
{{gh-input model.password tabindex="2" type="password" name="password" placeholder="At least 8 characters" onenter=(action "signup") autocorrect="off" focusOut=(action "validate" "password") update=(action (mut model.password))}}
</span>
{{gh-error-message errors=model.errors property="password"}}
{{/gh-form-group}}
</form>
{{#gh-form-group errors=model.errors hasValidated=hasValidated property="name"}}
<label for="full-name">Full name</label>
<span class="input-icon icon-user">
{{gh-trim-focus-input model.name tabindex="1" type="text" name="name" placeholder="Eg. John H. Watson" onenter=(action "signup") autocorrect="off" focusOut=(action "validate" "name") update=(action (mut model.name))}}
</span>
{{gh-error-message errors=model.errors property="name"}}
{{/gh-form-group}}
{{#gh-form-group errors=model.errors hasValidated=hasValidated property="password"}}
<label for="password">Password</label>
<span class="input-icon icon-lock">
{{gh-input model.password tabindex="2" type="password" name="password" placeholder="At least 8 characters" onenter=(action "signup") autocorrect="off" focusOut=(action "validate" "password") update=(action (mut model.password))}}
</span>
{{gh-error-message errors=model.errors property="password"}}
{{/gh-form-group}}
</form>
{{#gh-spin-button tabindex="3" type="submit" class="btn btn-green btn-lg btn-block" action="signup" submitting=submitting autoWidth="false"}}Create Account{{/gh-spin-button}}
{{/if}}
{{#gh-spin-button tabindex="3" type="submit" class="btn btn-green btn-lg btn-block" action="signup" submitting=submitting autoWidth="false"}}Create Account{{/gh-spin-button}}
<p class="main-error">{{{flowErrors}}}</p>
</section>
</div>

View File

@ -171,9 +171,10 @@
</fieldset>
</form> {{! user details form }}
<form class="user-profile" novalidate="novalidate" autocomplete="off" {{action (perform user.saveNewPassword) on="submit"}}>
{{!-- If an administrator is viewing Owner's profile then hide inputs for change password --}}
{{#unless isAdminUserOnOwnerProfile}}
{{!-- If an administrator is viewing Owner's profile or we're using Ghost.org OAuth then hide inputs for change password --}}
{{#if canChangePassword}}
<form id="password-reset" class="user-profile" novalidate="novalidate" autocomplete="off" {{action (perform user.saveNewPassword) on="submit"}}>
<fieldset>
{{#unless isNotOwnProfile}}
{{#gh-form-group errors=user.errors hasValidated=user.hasValidated property="password"}}
@ -199,7 +200,7 @@
{{#gh-task-button class="btn btn-red button-change-password" task=user.saveNewPassword}}Change Password{{/gh-task-button}}
</div>
</fieldset>
{{/unless}}
</form> {{! change password form }}
</form> {{! change password form }}
{{/if}}
</div>
</section>

View File

@ -0,0 +1,33 @@
import Oauth2 from 'torii/providers/oauth2-code';
import injectService from 'ember-service/inject';
import computed from 'ember-computed';
let GhostOauth2 = Oauth2.extend({
config: injectService(),
name: 'ghost-oauth2',
baseUrl: 'http://devauth.ghost.org:8080/oauth2/authorize',
apiKey: computed(function () {
return this.get('config.ghostAuthId');
}),
optionalUrlParams: ['type', 'email'],
responseParams: ['code'],
// we want to redirect to the ghost admin app by default
redirectUri: window.location.href.replace(/(\/ghost)(.*)/, '$1/'),
open(options) {
if (options.type) {
this.set('type', options.type);
}
if (options.email) {
this.set('email', options.email);
}
return this._super(...arguments);
}
});
export default GhostOauth2;

View File

@ -4,17 +4,23 @@ export default BaseValidator.extend({
properties: ['name', 'email', 'password'],
name(model) {
let usingOAuth = model.get('config.ghostOAuth');
let name = model.get('name');
if (!validator.isLength(name, 1)) {
if (!usingOAuth && !validator.isLength(name, 1)) {
model.get('errors').add('name', 'Please enter a name.');
this.invalidate();
}
},
email(model) {
let usingOAuth = model.get('config.ghostOAuth');
let email = model.get('email');
if (usingOAuth) {
return;
}
if (validator.empty(email)) {
model.get('errors').add('email', 'Please enter an email.');
this.invalidate();
@ -25,9 +31,10 @@ export default BaseValidator.extend({
},
password(model) {
let usingOAuth = model.get('config.ghostOAuth');
let password = model.get('password');
if (!validator.isLength(password, 8)) {
if (!usingOAuth && !validator.isLength(password, 8)) {
model.get('errors').add('password', 'Password must be at least 8 characters long');
this.invalidate();
}

View File

@ -1,7 +1,7 @@
import NewUserValidator from 'ghost-admin/validators/new-user';
export default NewUserValidator.create({
properties: ['name', 'email', 'password', 'blogTitle'],
properties: ['name', 'email', 'password', 'blogTitle', 'session'],
blogTitle(model) {
let blogTitle = model.get('blogTitle');
@ -10,5 +10,16 @@ export default NewUserValidator.create({
model.get('errors').add('blogTitle', 'Please enter a blog title.');
this.invalidate();
}
},
session(model) {
let usingOAuth = model.get('config.ghostOAuth');
let isAuthenticated = model.get('session.isAuthenticated');
if (usingOAuth && !isAuthenticated) {
model.get('errors').add('session', 'Please connect a Ghost.org account before continuing');
model.get('hasValidated').pushObject('session');
this.invalidate();
}
}
});

View File

@ -35,6 +35,10 @@ module.exports = function (environment) {
authenticationRoute: 'signin',
routeAfterAuthentication: 'posts',
routeIfAlreadyAuthenticated: 'posts'
},
torii: {
}
};

View File

@ -93,6 +93,7 @@
"moment-timezone": "0.5.5",
"password-generator": "2.0.2",
"top-gh-contribs": "2.0.4",
"torii": "0.8.0",
"walk-sync": "0.3.1"
},
"ember-addon": {

View File

@ -1,4 +1,5 @@
/* jshint expr:true */
import Ember from 'ember';
import {
describe,
it,
@ -10,6 +11,11 @@ import startApp from 'ghost-admin/tests/helpers/start-app';
import destroyApp from 'ghost-admin/tests/helpers/destroy-app';
import { invalidateSession, authenticateSession } from 'ghost-admin/tests/helpers/ember-simple-auth';
import Mirage from 'ember-cli-mirage';
import $ from 'jquery';
import {
stubSuccessfulOAuthConnect,
stubFailedOAuthConnect
} from 'ghost-admin/tests/helpers/oauth';
describe('Acceptance: Setup', function () {
let application;
@ -409,4 +415,121 @@ describe('Acceptance: Setup', function () {
});
});
});
describe('using Ghost OAuth', function () {
beforeEach(function () {
// mimic a new install
server.get('/authentication/setup/', function () {
return {
setup: [
{status: false}
]
};
});
// simulate active oauth config
$('head').append('<meta name="env-ghostAuthId" content="6e0704b3-c653-4c12-8da7-584232b5c629" />');
// ensure we have roles available
server.loadFixtures('roles');
});
afterEach(function () {
// ensure we don't leak OAuth config to other tests
$('meta[name="env-ghostAuthId"]').remove();
});
it('displays the connect form and validates', function () {
invalidateSession(application);
visit('/setup');
andThen(() => {
// it redirects to step one
expect(
currentURL(),
'url after accessing /setup'
).to.equal('/setup/one');
});
click('.btn-green');
andThen(() => {
expect(
find('button.login').text().trim(),
'login button text'
).to.equal('Sign in with Ghost');
});
click('.btn-green');
andThen(() => {
let sessionFG = find('button.login').closest('.form-group');
let titleFG = find('input[name="blog-title"]').closest('.form-group');
// session is validated
expect(
sessionFG.hasClass('error'),
'session form group has error class'
).to.be.true;
expect(
sessionFG.find('.response').text().trim(),
'session validation text'
).to.match(/Please connect a Ghost\.org account/i);
// blog title is validated
expect(
titleFG.hasClass('error'),
'title form group has error class'
).to.be.true;
expect(
titleFG.find('.response').text().trim(),
'title validation text'
).to.match(/please enter a blog title/i);
});
// TODO: test that connecting clears session validation error
// TODO: test that typing in blog title clears validation error
});
it('can connect and setup successfully', function () {
stubSuccessfulOAuthConnect(application);
visit('/setup/two');
click('button.login');
andThen(() => {
expect(
find('button.login').text().trim(),
'login button text when connected'
).to.equal('Connected: oauthtest@example.com');
});
fillIn('input[name="blog-title"]', 'Ghostbusters');
click('.btn-green');
andThen(() => {
expect(
currentURL(),
'url after submitting'
).to.equal('/setup/three');
});
});
it('handles failed connect', function () {
stubFailedOAuthConnect(application);
visit('/setup/two');
click('button.login');
andThen(() => {
expect(
find('.main-error').text().trim(),
'error text after failed oauth connect'
).to.match(/authentication with ghost\.org denied or failed/i);
});
});
});
});

View File

@ -11,6 +11,10 @@ import startApp from '../helpers/start-app';
import destroyApp from '../helpers/destroy-app';
import { invalidateSession, authenticateSession } from 'ghost-admin/tests/helpers/ember-simple-auth';
import Mirage from 'ember-cli-mirage';
import {
stubSuccessfulOAuthConnect,
stubFailedOAuthConnect
} from 'ghost-admin/tests/helpers/oauth';
describe('Acceptance: Signin', function() {
let application;
@ -129,4 +133,54 @@ describe('Acceptance: Signin', function() {
});
});
});
describe('using Ghost OAuth', function () {
beforeEach(function () {
// simulate active oauth config
$('head').append('<meta name="env-ghostAuthId" content="6e0704b3-c653-4c12-8da7-584232b5c629" />');
});
afterEach(function () {
// ensure we don't leak OAuth config to other tests
$('meta[name="env-ghostAuthId"]').remove();
});
it('can sign in successfully', function () {
server.loadFixtures('roles');
stubSuccessfulOAuthConnect(application);
visit('/signin');
andThen(() => {
expect(currentURL(), 'current url').to.equal('/signin');
expect(
find('button.login').text().trim(),
'login button text'
).to.equal('Sign in with Ghost');
});
click('button.login');
andThen(() => {
expect(currentURL(), 'url after connect').to.equal('/');
});
});
it('handles a failed connect', function () {
stubFailedOAuthConnect(application);
visit('/signin');
click('button.login');
andThen(() => {
expect(currentURL(), 'current url').to.equal('/signin');
expect(
find('.main-error').text().trim(),
'sign-in error'
).to.match(/Authentication with Ghost\.org denied or failed/i);
});
});
});
});

View File

@ -9,6 +9,10 @@ import { expect } from 'chai';
import startApp from '../helpers/start-app';
import destroyApp from '../helpers/destroy-app';
import $ from 'jquery';
import {
stubSuccessfulOAuthConnect,
stubFailedOAuthConnect
} from 'ghost-admin/tests/helpers/oauth';
describe('Acceptance: Signup', function() {
let application;
@ -24,6 +28,29 @@ describe('Acceptance: Signup', function() {
});
it('can signup successfully', function() {
server.get('/authentication/invitation', function (db, request) {
return {
invitation: [{valid: true}]
};
});
server.post('/authentication/invitation/', function (db, request) {
let params = JSON.parse(request.requestBody);
expect(params.invitation[0].name).to.equal('Test User');
expect(params.invitation[0].email).to.equal('kevin+test2@ghost.org');
expect(params.invitation[0].password).to.equal('ValidPassword');
expect(params.invitation[0].token).to.equal('MTQ3MDM0NjAxNzkyOXxrZXZpbit0ZXN0MkBnaG9zdC5vcmd8MmNEblFjM2c3ZlFUajluTks0aUdQU0dmdm9ta0xkWGY2OEZ1V2dTNjZVZz0');
// ensure that `/users/me/` request returns a user
server.create('user', {email: 'kevin@test2@ghost.org'});
return {
invitation: [{
message: 'Invitation accepted.'
}]
};
});
// token details:
// "1470346017929|kevin+test2@ghost.org|2cDnQc3g7fQTj9nNK4iGPSGfvomkLdXf68FuWgS66Ug="
visit('/signup/MTQ3MDM0NjAxNzkyOXxrZXZpbit0ZXN0MkBnaG9zdC5vcmd8MmNEblFjM2c3ZlFUajluTks0aUdQU0dmdm9ta0xkWGY2OEZ1V2dTNjZVZz0');
@ -108,29 +135,6 @@ describe('Acceptance: Signup', function() {
// submitting sends correct details and redirects to content screen
click('.btn-green');
server.get('/authentication/invitation', function (db, request) {
return {
invitation: [{valid: true}]
};
});
server.post('/authentication/invitation/', function (db, request) {
let params = JSON.parse(request.requestBody);
expect(params.invitation[0].name).to.equal('Test User');
expect(params.invitation[0].email).to.equal('kevin+test2@ghost.org');
expect(params.invitation[0].password).to.equal('ValidPassword');
expect(params.invitation[0].token).to.equal('MTQ3MDM0NjAxNzkyOXxrZXZpbit0ZXN0MkBnaG9zdC5vcmd8MmNEblFjM2c3ZlFUajluTks0aUdQU0dmdm9ta0xkWGY2OEZ1V2dTNjZVZz0');
// ensure that `/users/me/` request returns a user
server.create('user', {email: 'kevin@test2@ghost.org'});
return {
invitation: [{
message: 'Invitation accepted.'
}]
};
});
andThen(function () {
expect(currentPath()).to.equal('posts.index');
});
@ -139,4 +143,67 @@ describe('Acceptance: Signup', function() {
it('redirects if already logged in');
it('redirects with alert on invalid token');
it('redirects with alert on non-existant or expired token');
describe('using Ghost OAuth', function () {
beforeEach(function () {
// simulate active oauth config
$('head').append('<meta name="env-ghostAuthId" content="6e0704b3-c653-4c12-8da7-584232b5c629" />');
let user = server.create('user', {name: 'Test Invite Creator'});
/* jscs:disable requireCamelCaseOrUpperCaseIdentifiers */
server.create('invite', {
email: 'kevin+test2@ghost.org',
created_by: user.id
});
/* jscs:enable requireCamelCaseOrUpperCaseIdentifiers */
});
afterEach(function () {
// ensure we don't leak OAuth config to other tests
$('meta[name="env-ghostAuthId"]').remove();
});
it('can sign up sucessfully', function () {
stubSuccessfulOAuthConnect(application);
// token details:
// "1470346017929|kevin+test2@ghost.org|2cDnQc3g7fQTj9nNK4iGPSGfvomkLdXf68FuWgS66Ug="
visit('/signup/MTQ3MDM0NjAxNzkyOXxrZXZpbit0ZXN0MkBnaG9zdC5vcmd8MmNEblFjM2c3ZlFUajluTks0aUdQU0dmdm9ta0xkWGY2OEZ1V2dTNjZVZz0');
andThen(() => {
expect(currentPath()).to.equal('signup');
expect(
find('.gh-flow-content header p').text().trim(),
'form header text'
).to.equal('Accept your invite from Test Invite Creator');
});
click('button.login');
andThen(() => {
expect(currentPath()).to.equal('posts.index');
});
});
it('handles failed connect', function () {
stubFailedOAuthConnect(application);
// token details:
// "1470346017929|kevin+test2@ghost.org|2cDnQc3g7fQTj9nNK4iGPSGfvomkLdXf68FuWgS66Ug="
visit('/signup/MTQ3MDM0NjAxNzkyOXxrZXZpbit0ZXN0MkBnaG9zdC5vcmd8MmNEblFjM2c3ZlFUajluTks0aUdQU0dmdm9ta0xkWGY2OEZ1V2dTNjZVZz0');
click('button.login');
andThen(() => {
expect(currentPath()).to.equal('signup');
expect(
find('.main-error').text().trim(),
'flow error text'
).to.match(/authentication with ghost\.org denied or failed/i);
});
});
});
});

View File

@ -716,6 +716,44 @@ describe('Acceptance: Team', function () {
});
});
describe('using Ghost OAuth', function () {
beforeEach(function () {
// simulate active oauth config
$('head').append('<meta name="env-ghostAuthId" content="6e0704b3-c653-4c12-8da7-584232b5c629" />');
server.loadFixtures();
});
afterEach(function () {
// ensure we don't leak OAuth config to other tests
$('meta[name="env-ghostAuthId"]').remove();
});
it('doesn\'t show the password reset form', function () {
visit(`/team/${admin.slug}`);
andThen(() => {
// ensure that the normal form is displayed so we don't get
// false positives
expect(
find('input#user-slug').length,
'profile form is displayed'
).to.equal(1);
// check that the password form is hidden
expect(
find('#password-reset').length,
'presence of password reset form'
).to.equal(0);
expect(
find('#user-password-new').length,
'presence of new password field'
).to.equal(0);
});
});
});
describe('own user', function () {
beforeEach(function () {
server.loadFixtures();

39
tests/helpers/oauth.js Normal file
View File

@ -0,0 +1,39 @@
import {faker} from 'ember-cli-mirage';
import RSVP from 'rsvp';
let generateCode = function generateCode() {
return faker.internet.password(32, false, /[a-zA-Z0-9]/);
};
let generateSecret = function generateSecret() {
return faker.internet.password(12, false, /[a-f0-9]/);
};
const stubSuccessfulOAuthConnect = function stubSuccessfulOAuthConnect(application) {
let provider = application.__container__.lookup('torii-provider:ghost-oauth2');
provider.open = function () {
return RSVP.Promise.resolve({
/* jscs:disable requireCamelCaseOrUpperCaseIdentifiers */
authorizationCode: generateCode(),
client_id: 'ghost-admin',
client_secret: generateSecret(),
provider: 'ghost-oauth2',
redirectUrl: 'http://localhost:2368/ghost/'
/* jscs:enable requireCamelCaseOrUpperCaseIdentifiers */
});
};
};
const stubFailedOAuthConnect = function stubFailedOAuthConnect(application) {
let provider = application.__container__.lookup('torii-provider:ghost-oauth2');
provider.open = function () {
return RSVP.Promise.reject();
};
};
export {
stubSuccessfulOAuthConnect,
stubFailedOAuthConnect
};

View File

@ -17,6 +17,7 @@
<meta name="env-routeKeywords" content="{&quot;tag&quot;:&quot;tag&quot;,&quot;author&quot;:&quot;author&quot;,&quot;page&quot;:&quot;page&quot;,&quot;preview&quot;:&quot;p&quot;,&quot;private&quot;:&quot;private&quot;}" />
<meta name="env-clientId" content="ghost-admin" />
<meta name="env-clientSecret" content="5076dc643873" />
<!-- <meta name="env-ghostAuthId" content="6e0704b3-c653-4c12-8da7-584232b5c629" /> -->
<link rel="stylesheet" href="{{rootURL}}assets/vendor.css">
<link rel="stylesheet" href="{{rootURL}}assets/ghost.css">

View File

@ -185,8 +185,6 @@ describeComponent(
return $(name).text().trim();
}).toArray();
console.log(packageNames);
expect(
packageNames,
'themes are ordered by label, folder names shown for duplicates'