diff --git a/ghost/admin/app/adapters/base.js b/ghost/admin/app/adapters/base.js index ac7dbec947..3225efbb59 100644 --- a/ghost/admin/app/adapters/base.js +++ b/ghost/admin/app/adapters/base.js @@ -46,7 +46,6 @@ export default RESTAdapter.extend(DataAdapterMixin, { if (status === 401) { if (this.get('session.isAuthenticated')) { this.get('session').invalidate(); - return; // prevent error from bubbling because invalidate is async } } diff --git a/ghost/admin/app/components/modals/delete-post.js b/ghost/admin/app/components/modals/delete-post.js index 8ce18b7351..903d3fddb3 100644 --- a/ghost/admin/app/components/modals/delete-post.js +++ b/ghost/admin/app/components/modals/delete-post.js @@ -29,8 +29,8 @@ export default ModalComponent.extend({ this.get('routing').transitionTo('posts'); }, - _failure() { - this.get('notifications').showAlert('Your post could not be deleted. Please try again.', {type: 'error', key: 'post.delete.failed'}); + _failure(error) { + this.get('notifications').showAPIError(error, {key: 'post.delete.failed'}); }, actions: { @@ -39,8 +39,8 @@ export default ModalComponent.extend({ this._deletePost().then(() => { this._success(); - }, () => { - this._failure(); + }, (error) => { + this._failure(error); }).finally(() => { this.send('closeModal'); }); diff --git a/ghost/admin/app/components/modals/invite-new-user.js b/ghost/admin/app/components/modals/invite-new-user.js index 61c6dc8e5f..06984c2c67 100644 --- a/ghost/admin/app/components/modals/invite-new-user.js +++ b/ghost/admin/app/components/modals/invite-new-user.js @@ -1,6 +1,6 @@ import RSVP from 'rsvp'; import injectService from 'ember-service/inject'; -import {A as emberA, isEmberArray} from 'ember-array/utils'; +import {A as emberA} from 'ember-array/utils'; import run from 'ember-runloop'; import ModalComponent from 'ghost-admin/components/modals/base'; import ValidationEngine from 'ghost-admin/mixins/validation-engine'; @@ -111,13 +111,9 @@ export default ModalComponent.extend(ValidationEngine, { } else { notifications.showNotification(notificationText, {key: 'invite.send.success'}); } - }).catch((errors) => { + }).catch((error) => { newUser.deleteRecord(); - if (isEmberArray(errors)) { - notifications.showErrors(errors, {key: 'invite.send'}); - } else { - notifications.showAPIError(errors, {key: 'invite.send'}); - } + notifications.showAPIError(error, {key: 'invite.send'}); }).finally(() => { this.send('closeModal'); }); diff --git a/ghost/admin/app/controllers/post-settings-menu.js b/ghost/admin/app/controllers/post-settings-menu.js index 8ebad097ca..9aed6a6619 100644 --- a/ghost/admin/app/controllers/post-settings-menu.js +++ b/ghost/admin/app/controllers/post-settings-menu.js @@ -185,11 +185,8 @@ export default Controller.extend(SettingsMenuMixin, { }), showErrors(errors) { - if (isVersionMismatchError(errors)) { - return this.get('notifications').showAPIError(errors); - } errors = isEmberArray(errors) ? errors : [errors]; - this.get('notifications').showErrors(errors); + this.get('notifications').showAPIError(errors); }, actions: { diff --git a/ghost/admin/app/controllers/settings/apps/slack.js b/ghost/admin/app/controllers/settings/apps/slack.js index 1ee3e18ea1..3a4c65324b 100644 --- a/ghost/admin/app/controllers/settings/apps/slack.js +++ b/ghost/admin/app/controllers/settings/apps/slack.js @@ -61,9 +61,7 @@ export default Controller.extend({ this.set('isSaving', true); return settings.save().catch((err) => { - if (err && err.isAdapterError) { - this.get('notifications').showAPIError(err); - } + this.get('notifications').showAPIError(err); throw err; }).finally(() => { this.set('isSaving', false); diff --git a/ghost/admin/app/controllers/team/user.js b/ghost/admin/app/controllers/team/user.js index aa343c95dd..24787f7b8f 100644 --- a/ghost/admin/app/controllers/team/user.js +++ b/ghost/admin/app/controllers/team/user.js @@ -12,6 +12,7 @@ import { invoke } from 'ember-invoke-action'; export default Controller.extend({ submitting: false, + updatingPassword: false, lastPromise: null, showDeleteUserModal: false, showTransferOwnerModal: false, @@ -140,13 +141,12 @@ export default Controller.extend({ this.get('notifications').closeAlerts('user.update'); return model; - }).catch((errors) => { - if (isEmberArray(errors)) { - this.get('notifications').showErrors(errors, {key: 'user.update'}); - } else { - this.get('notifications').showAPIError(errors); + }).catch((error) => { + // validation engine returns undefined so we have to check + // before treating the failure as an API error + if (error) { + this.get('notifications').showAPIError(error, {key: 'user.update'}); } - this.toggleProperty('submitting'); }); @@ -168,11 +168,13 @@ export default Controller.extend({ } }, - password() { + changePassword() { let user = this.get('user'); - if (user.get('isPasswordValid')) { - user.saveNewPassword().then((model) => { + if (!this.get('updatingPassword')) { + this.set('updatingPassword', true); + + return user.saveNewPassword().then((model) => { // Clear properties from view user.setProperties({ password: '', @@ -180,15 +182,22 @@ export default Controller.extend({ ne2Password: '' }); - this.get('notifications').showAlert('Password updated.', {type: 'success', key: 'user.change-password.success'}); + this.get('notifications').showNotification('Password updated.', {type: 'success', key: 'user.change-password.success'}); + + // clear errors manually for ne2password because validation + // engine only clears the "validated proeprty" + // TODO: clean up once we have a better validations library + user.get('errors').remove('ne2Password'); return model; - }).catch((errors) => { - this.get('notifications').showAPIError(errors, {key: 'user.change-password'}); + }).catch((error) => { + // error will be undefined if we have a validation error + if (error) { + this.get('notifications').showAPIError(error, {key: 'user.change-password'}); + } + }).finally(() => { + this.set('updatingPassword', false); }); - } else { - // TODO: switch to in-line validation - this.get('notifications').showErrors(user.get('passwordValidationErrors'), {key: 'user.change-password'}); } }, @@ -419,6 +428,26 @@ export default Controller.extend({ toggleUploadImageModal() { this.toggleProperty('showUploadImageModal'); + }, + + // TODO: remove those mutation actions once we have better + // inline validations that auto-clear errors on input + updatePassword(password) { + this.set('user.password', password); + this.get('user.hasValidated').removeObject('password'); + this.get('user.errors').remove('password'); + }, + + updateNewPassword(password) { + this.set('user.newPassword', password); + this.get('user.hasValidated').removeObject('newPassword'); + this.get('user.errors').remove('newPassword'); + }, + + updateNe2Password(password) { + this.set('user.ne2Password', password); + this.get('user.hasValidated').removeObject('ne2Password'); + this.get('user.errors').remove('ne2Password'); } } }); diff --git a/ghost/admin/app/mirage/config.js b/ghost/admin/app/mirage/config.js index 3b504a0cce..e79ff78bf8 100644 --- a/ghost/admin/app/mirage/config.js +++ b/ghost/admin/app/mirage/config.js @@ -472,12 +472,19 @@ export function testConfig() { this.put('/users/:id/', function (db, request) { let {id} = request.params; - let [attrs] = JSON.parse(request.requestBody).users; - let record = db.users.update(id, attrs); - return { - user: record - }; + if (id === 'password') { + return { + password: [{message: 'Password changed successfully.'}] + }; + } else { + let [attrs] = JSON.parse(request.requestBody).users; + let record = db.users.update(id, attrs); + + return { + user: record + }; + } }); /* External sites ------------------------------------------------------- */ diff --git a/ghost/admin/app/models/user.js b/ghost/admin/app/models/user.js index 8643abe85f..ded203b62c 100644 --- a/ghost/admin/app/models/user.js +++ b/ghost/admin/app/models/user.js @@ -1,10 +1,9 @@ /* jscs:disable requireCamelCaseOrUpperCaseIdentifiers */ -import computed, {equal, empty} from 'ember-computed'; -import injectService from 'ember-service/inject'; - import Model from 'ember-data/model'; import attr from 'ember-data/attr'; -import { hasMany } from 'ember-data/relationships'; +import {hasMany} from 'ember-data/relationships'; +import computed, {equal} from 'ember-computed'; +import injectService from 'ember-service/inject'; import ValidationEngine from 'ghost-admin/mixins/validation-engine'; export default Model.extend(ValidationEngine, { @@ -39,6 +38,7 @@ export default Model.extend(ValidationEngine, { ghostPaths: injectService(), ajax: injectService(), + session: injectService(), // TODO: Once client-side permissions are in place, // remove the hard role check. @@ -47,7 +47,9 @@ export default Model.extend(ValidationEngine, { isAdmin: equal('role.name', 'Administrator'), isOwner: equal('role.name', 'Owner'), - isPasswordValid: empty('passwordValidationErrors.[]'), + isLoggedIn: computed('id', 'session.user.id', function () { + return this.get('id') === this.get('session.user.id'); + }), active: computed('status', function () { return ['active', 'warn-1', 'warn-2', 'warn-3', 'warn-4', 'locked'].indexOf(this.get('status')) > -1; @@ -59,20 +61,6 @@ export default Model.extend(ValidationEngine, { pending: equal('status', 'invited-pending'), - passwordValidationErrors: computed('password', 'newPassword', 'ne2Password', function () { - let validationErrors = []; - - if (!validator.equals(this.get('newPassword'), this.get('ne2Password'))) { - validationErrors.push({message: 'Your new passwords do not match'}); - } - - if (!validator.isLength(this.get('newPassword'), 8)) { - validationErrors.push({message: 'Your password is not long enough. It must be at least 8 characters long.'}); - } - - return validationErrors; - }), - role: computed('roles', { get() { return this.get('roles.firstObject'); @@ -88,16 +76,19 @@ export default Model.extend(ValidationEngine, { saveNewPassword() { let url = this.get('ghostPaths.url').api('users', 'password'); + let validation = this.get('isLoggedIn') ? 'ownPasswordChange' : 'passwordChange'; - return this.get('ajax').put(url, { - data: { - password: [{ - user_id: this.get('id'), - oldPassword: this.get('password'), - newPassword: this.get('newPassword'), - ne2Password: this.get('ne2Password') - }] - } + return this.validate({property: validation}).then(() => { + return this.get('ajax').put(url, { + data: { + password: [{ + user_id: this.get('id'), + oldPassword: this.get('password'), + newPassword: this.get('newPassword'), + ne2Password: this.get('ne2Password') + }] + } + }); }); }, diff --git a/ghost/admin/app/services/ajax.js b/ghost/admin/app/services/ajax.js index ccc93f819f..c5e38e2d3d 100644 --- a/ghost/admin/app/services/ajax.js +++ b/ghost/admin/app/services/ajax.js @@ -26,8 +26,22 @@ export function isVersionMismatchError(errorOrStatus, payload) { /* Request entity too large error */ +export function ServerUnreachableError(errors) { + AjaxError.call(this, errors, 'Server was unreachable'); +} + +ServerUnreachableError.prototype = Object.create(AjaxError.prototype); + +export function isServerUnreachableError(error) { + if (isAjaxError(error)) { + return error instanceof ServerUnreachableError; + } else { + return error === 0 || error === '0'; + } +} + export function RequestEntityTooLargeError(errors) { - AjaxError.call(this, errors, 'Request was rejected because it\'s larger than the maximum file size the server allows'); + AjaxError.call(this, errors, 'Request is larger than the maximum file size the server allows'); } RequestEntityTooLargeError.prototype = Object.create(AjaxError.prototype); @@ -43,7 +57,7 @@ export function isRequestEntityTooLargeError(errorOrStatus) { /* Unsupported media type error */ export function UnsupportedMediaTypeError(errors) { - AjaxError.call(this, errors, 'Request was rejected because it contains an unknown or unsupported file type.'); + AjaxError.call(this, errors, 'Request contains an unknown or unsupported file type.'); } UnsupportedMediaTypeError.prototype = Object.create(AjaxError.prototype); @@ -79,6 +93,8 @@ export default AjaxService.extend({ handleResponse(status, headers, payload) { if (this.isVersionMismatchError(status, headers, payload)) { return new VersionMismatchError(payload.errors); + } else if (this.isServerUnreachableError(status, headers, payload)) { + return new ServerUnreachableError(payload.errors); } else if (this.isRequestEntityTooLargeError(status, headers, payload)) { return new RequestEntityTooLargeError(payload.errors); } else if (this.isUnsupportedMediaTypeError(status, headers, payload)) { @@ -110,6 +126,10 @@ export default AjaxService.extend({ return isVersionMismatchError(status, payload); }, + isServerUnreachableError(status/*, headers, payload */) { + return isServerUnreachableError(status); + }, + isRequestEntityTooLargeError(status/*, headers, payload */) { return isRequestEntityTooLargeError(status); }, diff --git a/ghost/admin/app/services/notifications.js b/ghost/admin/app/services/notifications.js index 399137b875..e7078719d3 100644 --- a/ghost/admin/app/services/notifications.js +++ b/ghost/admin/app/services/notifications.js @@ -5,6 +5,8 @@ import get from 'ember-metal/get'; import set from 'ember-metal/set'; import injectService from 'ember-service/inject'; import {isVersionMismatchError} from 'ghost-admin/services/ajax'; +import {isBlank} from 'ember-utils'; +import {dasherize} from 'ember-string'; // Notification keys take the form of "noun.verb.message", eg: // @@ -82,25 +84,6 @@ export default Service.extend({ }, options.delayed); }, - // TODO: review whether this can be removed once no longer used by validations - showErrors(errors, options) { - options = options || {}; - options.type = options.type || 'error'; - // TODO: getting keys from the server would be useful here (necessary for i18n) - options.key = (options.key && `${options.key}.api-error`) || 'api-error'; - - if (!options.doNotCloseNotifications) { - this.closeNotifications(); - } - - // ensure all errors that are passed in get shown - options.doNotCloseNotifications = true; - - for (let i = 0; i < errors.length; i += 1) { - this.showNotification(errors[i].message || errors[i], options); - } - }, - showAPIError(resp, options) { // handle "global" errors if (isVersionMismatchError(resp)) { @@ -120,24 +103,26 @@ export default Service.extend({ _showAPIError(resp, options) { options = options || {}; options.type = options.type || 'error'; - // TODO: getting keys from the server would be useful here (necessary for i18n) - options.key = (options.key && `${options.key}.api-error`) || 'api-error'; - if (!options.doNotCloseNotifications) { - this.closeNotifications(); + // if possible use the title to get a unique key + // - we only show one alert for each key so if we get multiple errors + // only the last one will be shown + if (!options.key && !isBlank(get(resp, 'title'))) { + options.key = dasherize(get(resp, 'title')); + } + options.key = ['api-error', options.key].compact().join('.'); + + let msg = options.defaultErrorText || 'There was a problem on the server, please try again.'; + + if (resp instanceof String) { + msg = resp; + } else if (!isBlank(get(resp, 'detail'))) { + msg = resp.detail; + } else if (!isBlank(get(resp, 'message'))) { + msg = resp.message; } - options.defaultErrorText = options.defaultErrorText || 'There was a problem on the server, please try again.'; - - if (resp && isEmberArray(resp) && resp.length) { // Array of errors - this.showErrors(resp, options); - } else if (resp && resp.message) { - this.showAlert(resp.message, options); - } else if (resp && resp.detail) { // ember-ajax provided error message - this.showAlert(resp.detail, options); - } else { // text error or no error - this.showAlert(resp || options.defaultErrorText, options); - } + this.showAlert(msg, options); }, displayDelayed() { diff --git a/ghost/admin/app/templates/team/user.hbs b/ghost/admin/app/templates/team/user.hbs index d256a21ad4..2a4db7f341 100644 --- a/ghost/admin/app/templates/team/user.hbs +++ b/ghost/admin/app/templates/team/user.hbs @@ -171,24 +171,27 @@ {{#unless isAdminUserOnOwnerProfile}}
{{/unless}} diff --git a/ghost/admin/app/utils/ajax.js b/ghost/admin/app/utils/ajax.js deleted file mode 100644 index bb01e4527f..0000000000 --- a/ghost/admin/app/utils/ajax.js +++ /dev/null @@ -1,47 +0,0 @@ -import {isEmberArray} from 'ember-array/utils'; - -// TODO: this should be removed and instead have our app serializer properly -// process the response so that errors can be tied to the model - -// Used in API request fail handlers to parse a standard api error -// response json for the message to display -export default function getRequestErrorMessage(request, performConcat) { - let message, - msgDetail; - - // Can't really continue without a request - if (!request) { - return null; - } - - // Seems like a sensible default - message = request.statusText; - - // If a non 200 response - if (request.status !== 200) { - try { - // Try to parse out the error, or default to 'Unknown' - if (request.errors && isEmberArray(request.errors)) { - message = request.errors.map((errorItem) => { - return errorItem.message; - }); - } else { - message = request.error || 'Unknown Error'; - } - } catch (e) { - msgDetail = request.status ? `${request.status} - ${request.statusText}` : 'Server was not available'; - message = `The server returned an error (${msgDetail}).`; - } - } - - if (performConcat && isEmberArray(message)) { - message = message.join('