mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-27 00:52:36 +03:00
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:
parent
0b14b91789
commit
a258e3d881
41
ghost/admin/app/authenticators/oauth2-ghost.js
Normal file
41
ghost/admin/app/authenticators/oauth2-ghost.js
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -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) {
|
||||
|
@ -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.');
|
||||
});
|
||||
|
@ -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`;
|
||||
|
@ -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'
|
||||
};
|
||||
});
|
||||
}
|
||||
|
@ -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
ghost/admin/app/routes/setup/two.js
Normal file
67
ghost/admin/app/routes/setup/two.js
Normal 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');
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
@ -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'});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -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'});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -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'));
|
||||
})
|
||||
});
|
||||
|
@ -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}}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
33
ghost/admin/app/torii-providers/ghost-oauth2.js
Normal file
33
ghost/admin/app/torii-providers/ghost-oauth2.js
Normal 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;
|
@ -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();
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -35,6 +35,10 @@ module.exports = function (environment) {
|
||||
authenticationRoute: 'signin',
|
||||
routeAfterAuthentication: 'posts',
|
||||
routeIfAlreadyAuthenticated: 'posts'
|
||||
},
|
||||
|
||||
torii: {
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -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": {
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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
ghost/admin/tests/helpers/oauth.js
Normal file
39
ghost/admin/tests/helpers/oauth.js
Normal 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
|
||||
};
|
@ -17,6 +17,7 @@
|
||||
<meta name="env-routeKeywords" content="{"tag":"tag","author":"author","page":"page","preview":"p","private":"private"}" />
|
||||
<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">
|
||||
|
@ -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'
|
||||
|
Loading…
Reference in New Issue
Block a user