1
0
Fork 0
mirror of https://github.com/TryGhost/Ghost-Admin.git synced 2023-12-14 02:33:04 +01:00

🎨 refactor signin screen to use ember-concurrency & gh-task-button (#571)

refs https://github.com/TryGhost/Ghost/issues/7865
- convert all signin related actions to ember-concurrency tasks and consolidate in the signin controller rather than spread across controller+route
- add `successClass` and `failureClass` params to `gh-task-button` that can be used to override the default success/failure button classes
- prevent clicks on `gh-task-button` from triggering form actions (this behaviour should never be necessary, task buttons should either be separate to the form as in the "forgot?" button or the form action performs the same task and can be triggered by a standard form submit)
This commit is contained in:
Kevin Ansfield 2017-03-09 21:48:54 +00:00 committed by Austin Burdine
parent 35ffe11661
commit d95b2b1d31
5 changed files with 179 additions and 124 deletions

View file

@ -17,7 +17,7 @@ import {invokeAction} from 'ember-invoke-action';
*/
const GhTaskButton = Component.extend({
tagName: 'button',
classNameBindings: ['isRunning:appear-disabled', 'isSuccess:gh-btn-green', 'isFailure:gh-btn-red'],
classNameBindings: ['isRunning:appear-disabled', 'isSuccessClass', 'isFailureClass'],
attributeBindings: ['disabled', 'type', 'tabindex'],
task: null,
@ -25,7 +25,9 @@ const GhTaskButton = Component.extend({
buttonText: 'Save',
runningText: reads('buttonText'),
successText: 'Saved',
successClass: 'gh-btn-green',
failureText: 'Retry',
failureClass: 'gh-btn-red',
isRunning: reads('task.last.isRunning'),
@ -38,6 +40,12 @@ const GhTaskButton = Component.extend({
return !isBlank(value) && value !== false;
}),
isSuccessClass: computed('isSuccess', function () {
if (this.get('isSuccess')) {
return this.get('successClass');
}
}),
isFailure: computed('isRunning', 'isSuccess', 'task.last.error', function () {
if (this.get('isRunning') || this.get('isSuccess')) {
return false;
@ -46,6 +54,12 @@ const GhTaskButton = Component.extend({
return this.get('task.last.error') !== undefined;
}),
isFailureClass: computed('isFailure', function () {
if (this.get('isFailure')) {
return this.get('failureClass');
}
}),
isIdle: computed('isRunning', 'isSuccess', 'isFailure', function () {
return !this.get('isRunning') && !this.get('isSuccess') && !this.get('isFailure');
}),
@ -68,8 +82,10 @@ const GhTaskButton = Component.extend({
}
invokeAction(this, 'action');
task.perform();
return task.perform();
// prevent the click from bubbling and triggering form actions
return false;
},
setSize: observer('isRunning', function () {

View file

@ -3,10 +3,8 @@ import Controller from 'ember-controller';
import injectService from 'ember-service/inject';
import injectController from 'ember-controller/inject';
import {isEmberArray} from 'ember-array/utils';
import {
isVersionMismatchError
} from 'ghost-admin/services/ajax';
import {task} from 'ember-concurrency';
import {isVersionMismatchError} from 'ghost-admin/services/ajax';
import ValidationEngine from 'ghost-admin/mixins/validation-engine';
export default Controller.extend(ValidationEngine, {
@ -20,72 +18,130 @@ export default Controller.extend(ValidationEngine, {
ghostPaths: injectService(),
notifications: injectService(),
session: injectService(),
torii: injectService(),
flowErrors: '',
// ValidationEngine settings
validationType: 'signin',
actions: {
validateAndAuthenticate() {
let model = this.get('model');
let authStrategy = 'authenticator:oauth2';
authenticate: task(function* (authStrategy, authentication) {
try {
return yield this.get('session')
.authenticate(authStrategy, ...authentication);
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
$('#login').find('input').trigger('change');
} 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(error)) {
return this.get('notifications').showAPIError(error);
}
// This is a bit dirty, but there's no other way to ensure the properties are set as well as 'signin'
this.get('hasValidated').addObjects(this.authProperties);
this.validate({property: 'signin'}).then(() => {
this.toggleProperty('loggingIn');
this.send('authenticate', authStrategy, [model.get('identification'), model.get('password')]);
}).catch(() => {
this.set('flowErrors', 'Please fill out the form to sign in.');
});
},
forgotten() {
let email = this.get('model.identification');
let notifications = this.get('notifications');
this.set('flowErrors', '');
// This is a bit dirty, but there's no other way to ensure the properties are set as well as 'forgotPassword'
this.get('hasValidated').addObject('identification');
this.validate({property: 'forgotPassword'}).then(() => {
let forgottenUrl = this.get('ghostPaths.url').api('authentication', 'passwordreset');
this.toggleProperty('submitting');
this.get('ajax').post(forgottenUrl, {
data: {
passwordreset: [{email}]
}
}).then(() => {
this.toggleProperty('submitting');
notifications.showAlert('Please check your email for instructions.', {type: 'info', key: 'forgot-password.send.success'});
}).catch((error) => {
this.toggleProperty('submitting');
if (isVersionMismatchError(error)) {
return notifications.showAPIError(error);
}
if (error && error.errors && isEmberArray(error.errors)) {
let [{message}] = error.errors;
this.set('flowErrors', message);
if (message.match(/no user with that email/)) {
this.get('model.errors').add('identification', '');
}
} else {
notifications.showAPIError(error, {defaultErrorText: 'There was a problem with the reset, please try again.', key: 'forgot-password.send'});
}
error.errors.forEach((err) => {
err.message = err.message.htmlSafe();
});
}).catch(() => {
this.set('flowErrors', 'We need your email address to reset your password!');
});
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'});
}
}
}).drop(),
validateAndAuthenticate: task(function* () {
let model = this.get('model');
let authStrategy = 'authenticator:oauth2';
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
$('#login').find('input').trigger('change');
// This is a bit dirty, but there's no other way to ensure the properties are set as well as 'signin'
this.get('hasValidated').addObjects(this.authProperties);
try {
yield this.validate({property: 'signin'});
return yield this.get('authenticate')
.perform(authStrategy, [model.get('identification'), model.get('password')]);
} catch (error) {
this.set('flowErrors', 'Please fill out the form to sign in.');
}
}).drop(),
authenticateWithGhostOrg: task(function* () {
let authStrategy = 'authenticator:oauth2-ghost';
this.set('flowErrors', '');
try {
let authentication = yield this.get('torii')
.open('ghost-oauth2', {type: 'signin'});
return yield this.get('authenticate').perform(authStrategy, [authentication]);
} catch (error) {
this.set('flowErrors', 'Authentication with Ghost.org denied or failed');
}
}).drop(),
forgotten: task(function* () {
let email = this.get('model.identification');
let forgottenUrl = this.get('ghostPaths.url').api('authentication', 'passwordreset');
let notifications = this.get('notifications');
this.set('flowErrors', '');
// This is a bit dirty, but there's no other way to ensure the properties are set as well as 'forgotPassword'
this.get('hasValidated').addObject('identification');
try {
yield this.validate({property: 'forgotPassword'});
yield this.get('ajax').post(forgottenUrl, {data: {passwordreset: [{email}]}});
notifications.showAlert(
'Please check your email for instructions.',
{type: 'info', key: 'forgot-password.send.success'}
);
return true;
} catch (error) {
// ValidationEngine throws "undefined" for failed validation
if (!error) {
return this.set('flowErrors', 'We need your email address to reset your password!');
}
if (isVersionMismatchError(error)) {
return notifications.showAPIError(error);
}
if (error && error.errors && isEmberArray(error.errors)) {
let [{message}] = error.errors;
this.set('flowErrors', message);
if (message.match(/no user with that email/)) {
this.get('model.errors').add('identification', '');
}
} else {
notifications.showAPIError(error, {defaultErrorText: 'There was a problem with the reset, please try again.', key: 'forgot-password.send'});
}
}
}),
actions: {
authenticate() {
this.get('validateAndAuthenticate').perform();
}
}
});

View file

@ -3,9 +3,6 @@ import injectService from 'ember-service/inject';
import EmberObject from 'ember-object';
import styleBody from 'ghost-admin/mixins/style-body';
import DS from 'ember-data';
import {
isVersionMismatchError
} from 'ghost-admin/services/ajax';
import UnauthenticatedRouteMixin from 'ember-simple-auth/mixins/unauthenticated-route-mixin';
const {Errors} = DS;
@ -37,59 +34,5 @@ export default Route.extend(UnauthenticatedRouteMixin, 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', '');
return 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
return 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(error)) {
return this.get('notifications').showAPIError(error);
}
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

@ -7,9 +7,9 @@
</header>
{{/if}}
<form id="login" class="gh-signin" method="post" novalidate="novalidate">
<form id="login" method="post" class="gh-signin" novalidate="novalidate" {{action "authenticate" on="submit"}}>
{{#if config.ghostOAuth}}
{{#gh-spin-button class="login gh-btn gh-btn-blue gh-btn-block" type="submit" action="authenticateWithGhostOrg" tabindex="3" submitting=loggingIn autoWidth="false"}}<span>Sign in with Ghost</span>{{/gh-spin-button}}
{{gh-task-button "Sign in with Ghost" task=authenticateWithGhostOrg class="login gh-btn gh-btn-blue gh-btn-block" tabindex="3"}}
{{else}}
{{#gh-form-group errors=model.errors hasValidated=hasValidated property="identification"}}
<span class="input-icon icon-mail">
@ -19,10 +19,12 @@
{{#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 gh-btn gh-btn-link" type="button" action="forgotten" tabindex="4" submitting=submitting autoWidth="true"}}<span>Forgot?</span>{{/gh-spin-button}}
{{#gh-task-button task=forgotten class="forgotten-link gh-btn gh-btn-link" successClass="" failureClass="" tabindex="4" type="button" as |task|}}
<span>{{#if task.isRunning}}<span class="spinner"></span>{{else}}Forgot?{{/if}}</span>
{{/gh-task-button}}
</span>
{{/gh-form-group}}
{{#gh-spin-button class="login gh-btn gh-btn-blue gh-btn-block" type="submit" action="validateAndAuthenticate" tabindex="3" submitting=loggingIn autoWidth="false"}}<span>Sign in</span>{{/gh-spin-button}}
{{gh-task-button "Sign in" task=validateAndAuthenticate class="login gh-btn gh-btn-blue gh-btn-block" type="submit" tabindex="3"}}
{{/if}}
</form>

View file

@ -94,6 +94,25 @@ describe('Integration: Component: gh-task-button', function() {
wait().then(done);
});
it('assigns specified success class on success', function (done) {
this.set('myTask', task(function* () {
yield timeout(50);
return true;
}));
this.render(hbs`{{gh-task-button task=myTask successClass="im-a-success"}}`);
this.get('myTask').perform();
run.later(this, function () {
expect(this.$('button')).to.not.have.class('gh-btn-green');
expect(this.$('button')).to.have.class('im-a-success');
expect(this.$('button')).to.contain('Saved');
}, 70);
wait().then(done);
});
it('shows failure when task errors', function (done) {
this.set('myTask', task(function* () {
try {
@ -134,6 +153,25 @@ describe('Integration: Component: gh-task-button', function() {
wait().then(done);
});
it('assigns specified failure class on failure', function (done) {
this.set('myTask', task(function* () {
yield timeout(50);
return false;
}));
this.render(hbs`{{gh-task-button task=myTask failureClass="im-a-failure"}}`);
this.get('myTask').perform();
run.later(this, function () {
expect(this.$('button')).to.not.have.class('gh-btn-red');
expect(this.$('button')).to.have.class('im-a-failure');
expect(this.$('button')).to.contain('Retry');
}, 70);
wait().then(done);
});
it('performs task on click', function (done) {
let taskCount = 0;