Skip to content
This repository has been archived by the owner on Nov 28, 2022. It is now read-only.

Commit

Permalink
🎨 refactor signin screen to use ember-concurrency & gh-task-button (#571
Browse files Browse the repository at this point in the history
)

refs TryGhost/Ghost#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)
  • Loading branch information
kevinansfield authored and acburdine committed Mar 9, 2017
1 parent 35ffe11 commit d95b2b1
Show file tree
Hide file tree
Showing 5 changed files with 181 additions and 126 deletions.
20 changes: 18 additions & 2 deletions app/components/gh-task-button.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,17 @@ 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,
disabled: false,
buttonText: 'Save',
runningText: reads('buttonText'),
successText: 'Saved',
successClass: 'gh-btn-green',
failureText: 'Retry',
failureClass: 'gh-btn-red',

isRunning: reads('task.last.isRunning'),

Expand All @@ -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;
Expand All @@ -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');
}),
Expand All @@ -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 () {
Expand Down
182 changes: 119 additions & 63 deletions app/controllers/signin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand All @@ -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';

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);
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'});
}
authenticate: task(function* (authStrategy, authentication) {
try {
return yield this.get('session')
.authenticate(authStrategy, ...authentication);

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

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();
}
}
});
57 changes: 0 additions & 57 deletions app/routes/signin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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'});
}
});
}
}
});
10 changes: 6 additions & 4 deletions app/templates/signin.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand All @@ -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>

Expand Down
Loading

0 comments on commit d95b2b1

Please sign in to comment.