diff --git a/ghost/admin/app/adapters/embedded-relation-adapter.js b/ghost/admin/app/adapters/embedded-relation-adapter.js index 2f9405ccf4..d57414985f 100644 --- a/ghost/admin/app/adapters/embedded-relation-adapter.js +++ b/ghost/admin/app/adapters/embedded-relation-adapter.js @@ -1,6 +1,7 @@ import BaseAdapter from 'ghost-admin/adapters/base'; import {get} from '@ember/object'; import {isNone} from '@ember/utils'; +import {underscore} from '@ember/string'; // EmbeddedRelationAdapter will augment the query object in calls made to // DS.Store#findRecord, findAll, query, and queryRecord with the correct "includes" @@ -76,7 +77,7 @@ export default BaseAdapter.extend({ let url = this.buildURL(modelName, id, snapshot, requestType, query); if (includes.length) { - url += `?include=${includes.join(',')}`; + url += `?include=${includes.map(underscore).join(',')}`; } return url; @@ -92,7 +93,7 @@ export default BaseAdapter.extend({ if (typeof options === 'string' || typeof options === 'number') { query = {}; query.id = options; - query.include = toInclude.join(','); + query.include = toInclude.map(underscore).join(','); } else if (typeof options === 'object' || isNone(options)) { // If this is a find all (no existing query object) build one and attach // the includes. diff --git a/ghost/admin/app/components/modal-new-integration.js b/ghost/admin/app/components/modal-new-integration.js new file mode 100644 index 0000000000..f9e0861099 --- /dev/null +++ b/ghost/admin/app/components/modal-new-integration.js @@ -0,0 +1,51 @@ +import ModalComponent from 'ghost-admin/components/modal-base'; +import {alias} from '@ember/object/computed'; +import {A as emberA} from '@ember/array'; +import {isInvalidError} from 'ember-ajax/errors'; +import {inject as service} from '@ember/service'; +import {task} from 'ember-concurrency'; + +export default ModalComponent.extend({ + router: service(), + + confirm() {}, + + integration: alias('model'), + + actions: { + updateName(name) { + this.integration.set('name', name); + this.integration.set('hasValidated', emberA()); + this.integration.errors.clear(); + }, + + confirm() { + return this.createIntegration.perform(); + } + }, + + createIntegration: task(function* () { + try { + let integration = yield this.confirm(); + this.router.transitionTo('settings.integration', integration); + } catch (error) { + // TODO: server-side validation errors should be serialized + // properly so that errors are added to model.errors automatically + if (error && isInvalidError(error)) { + let [firstError] = error.payload.errors; + let {message} = firstError; + + if (message && message.match(/name/i)) { + this.get('integration.errors').add('name', message); + this.get('integration.hasValidated').pushObject('name'); + return; + } + } + + // bubble up to the global error handler + if (error) { + throw error; + } + } + }).drop() +}); diff --git a/ghost/admin/app/components/modal-new-webhook.js b/ghost/admin/app/components/modal-new-webhook.js new file mode 100644 index 0000000000..d8eb20fe16 --- /dev/null +++ b/ghost/admin/app/components/modal-new-webhook.js @@ -0,0 +1,68 @@ +import ModalComponent from 'ghost-admin/components/modal-base'; +import Webhook from 'ghost-admin/models/webhook'; +import {alias} from '@ember/object/computed'; +import {camelize} from '@ember/string'; +import {isInvalidError} from 'ember-ajax/errors'; +import {inject as service} from '@ember/service'; +import {task} from 'ember-concurrency'; + +export default ModalComponent.extend({ + router: service(), + + availableEvents: null, + + confirm() {}, + + webhook: alias('model'), + + init() { + this._super(...arguments); + this.availableEvents = [ + {event: 'site.changed', name: 'Site Changed (rebuild)'}, + {event: 'subscriber.added', name: 'Subscriber Added'}, + {event: 'subscriber.deleted', name: 'Subscriber Deleted'} + ]; + }, + + actions: { + selectEvent(value) { + this.webhook.set('event', value); + this.webhook.validate({property: 'event'}); + }, + + confirm() { + this.createWebhook.perform(); + } + }, + + createWebhook: task(function* () { + try { + let webhook = yield this.confirm(); + let integration = yield webhook.get('integration'); + this.router.transitionTo('settings.integration', integration); + } catch (error) { + // TODO: server-side validation errors should be serialized + // properly so that errors are added to model.errors automatically + if (error && isInvalidError(error)) { + let attrs = Array.from(Webhook.attributes.keys()); + + error.payload.errors.forEach((error) => { + let {message, property} = error; + property = camelize(property); + + if (property && attrs.includes(property)) { + this.webhook.errors.add(property, message); + this.webhook.hasValidated.pushObject(property); + } + }); + + return; + } + + // bubble up to the global error handler + if (error) { + throw error; + } + } + }) +}); diff --git a/ghost/admin/app/controllers/settings/integration.js b/ghost/admin/app/controllers/settings/integration.js new file mode 100644 index 0000000000..7bec53a6ce --- /dev/null +++ b/ghost/admin/app/controllers/settings/integration.js @@ -0,0 +1,87 @@ +import Controller from '@ember/controller'; +import {alias} from '@ember/object/computed'; +import {computed} from '@ember/object'; +import {task} from 'ember-concurrency'; + +export default Controller.extend({ + integration: alias('model'), + + allWebhooks: computed(function () { + return this.store.peekAll('webhook'); + }), + + filteredWebhooks: computed('allWebhooks.@each.{isNew,isDeleted}', function () { + return this.allWebhooks.filter((webhook) => { + let matchesIntegration = webhook.belongsTo('integration').id() === this.integration.id; + + return matchesIntegration + && !webhook.isNew + && !webhook.isDeleted; + }); + }), + + actions: { + save() { + return this.save.perform(); + }, + + copyContentKey() { + this._copyInputTextToClipboard('input#content_key'); + }, + + copyAdminKey() { + this._copyInputTextToClipboard('input#admin_key'); + }, + + toggleUnsavedChangesModal(transition) { + let leaveTransition = this.leaveScreenTransition; + + if (!transition && this.showUnsavedChangesModal) { + this.set('leaveScreenTransition', null); + this.set('showUnsavedChangesModal', false); + return; + } + + if (!leaveTransition || transition.targetName === leaveTransition.targetName) { + this.set('leaveScreenTransition', transition); + + // if a save is running, wait for it to finish then transition + if (this.save.isRunning) { + return this.save.last.then(() => { + transition.retry(); + }); + } + + // we genuinely have unsaved data, show the modal + this.set('showUnsavedChangesModal', true); + } + }, + + leaveScreen() { + let transition = this.leaveScreenTransition; + + if (!transition) { + this.notifications.showAlert('Sorry, there was an error in the application. Please let the Ghost team know what happened.', {type: 'error'}); + return; + } + + // roll back changes on model props + this.integration.rollbackAttributes(); + + return transition.retry(); + } + }, + + save: task(function* () { + return yield this.integration.save(); + }), + + _copyInputTextToClipboard(selector) { + let input = document.querySelector(selector); + input.disabled = false; + input.focus(); + input.select(); + document.execCommand('copy'); + input.disabled = true; + } +}); diff --git a/ghost/admin/app/controllers/settings/integration/webhooks/new.js b/ghost/admin/app/controllers/settings/integration/webhooks/new.js new file mode 100644 index 0000000000..6cc9234613 --- /dev/null +++ b/ghost/admin/app/controllers/settings/integration/webhooks/new.js @@ -0,0 +1,19 @@ +import Controller from '@ember/controller'; +import {alias} from '@ember/object/computed'; + +export default Controller.extend({ + webhook: alias('model'), + + actions: { + save() { + return this.webhook.save(); + }, + + cancel() { + // 'new' route's dectivate hook takes care of rollback + return this.webhook.get('integration').then((integration) => { + this.transitionToRoute('settings.integration', integration); + }); + } + } +}); diff --git a/ghost/admin/app/controllers/settings/integrations.js b/ghost/admin/app/controllers/settings/integrations.js index 137009b32f..1e3b070424 100644 --- a/ghost/admin/app/controllers/settings/integrations.js +++ b/ghost/admin/app/controllers/settings/integrations.js @@ -1,7 +1,31 @@ /* eslint-disable ghost/ember/alias-model-in-controller */ import Controller from '@ember/controller'; +import {computed} from '@ember/object'; import {inject as service} from '@ember/service'; +import {task} from 'ember-concurrency'; export default Controller.extend({ - settings: service() + config: service(), + settings: service(), + store: service(), + + _allIntegrations: null, + + init() { + this._super(...arguments); + this._allIntegrations = this.store.peekAll('integration'); + }, + + // filter over the live query so that the list is automatically updated + // as integrations are added/removed + integrations: computed('_allIntegrations.@each.isNew', function () { + return this._allIntegrations.rejectBy('isNew', true); + }), + + // use ember-concurrency so that we can use the derived state to show + // a spinner only in the integrations list and avoid delaying the whole + // screen display + fetchIntegrations: task(function* () { + return yield this.store.findAll('integration'); + }) }); diff --git a/ghost/admin/app/controllers/settings/integrations/new.js b/ghost/admin/app/controllers/settings/integrations/new.js new file mode 100644 index 0000000000..3ce84d1256 --- /dev/null +++ b/ghost/admin/app/controllers/settings/integrations/new.js @@ -0,0 +1,17 @@ +import Controller from '@ember/controller'; +import {alias} from '@ember/object/computed'; + +export default Controller.extend({ + integration: alias('model'), + + actions: { + save() { + return this.integration.save(); + }, + + cancel() { + // 'new' route's dectivate hook takes care of rollback + this.transitionToRoute('settings.integrations'); + } + } +}); diff --git a/ghost/admin/app/helpers/integration-icon-style.js b/ghost/admin/app/helpers/integration-icon-style.js new file mode 100644 index 0000000000..d95c3c32cd --- /dev/null +++ b/ghost/admin/app/helpers/integration-icon-style.js @@ -0,0 +1,11 @@ +import {helper} from '@ember/component/helper'; +import {htmlSafe} from '@ember/string'; + +export function integrationLogoStyle([integration]/*, hash*/) { + if (integration.iconImage) { + let style = `background-image:url(${integration.iconImage});background-size:45px;`; + return htmlSafe(style); + } +} + +export default helper(integrationLogoStyle); diff --git a/ghost/admin/app/mixins/validation-engine.js b/ghost/admin/app/mixins/validation-engine.js index 8a07662961..ed3c22c49d 100644 --- a/ghost/admin/app/mixins/validation-engine.js +++ b/ghost/admin/app/mixins/validation-engine.js @@ -1,4 +1,5 @@ import DS from 'ember-data'; +import IntegrationValidator from 'ghost-admin/validators/integration'; import InviteUserValidator from 'ghost-admin/validators/invite-user'; import Mixin from '@ember/object/mixin'; import Model from 'ember-data/model'; @@ -14,6 +15,7 @@ import SlackIntegrationValidator from 'ghost-admin/validators/slack-integration' import SubscriberValidator from 'ghost-admin/validators/subscriber'; import TagSettingsValidator from 'ghost-admin/validators/tag-settings'; import UserValidator from 'ghost-admin/validators/user'; +import WebhookValidator from 'ghost-admin/validators/webhook'; import {A as emberA, isArray as isEmberArray} from '@ember/array'; const {Errors} = DS; @@ -42,7 +44,9 @@ export default Mixin.create({ slackIntegration: SlackIntegrationValidator, subscriber: SubscriberValidator, tag: TagSettingsValidator, - user: UserValidator + user: UserValidator, + integration: IntegrationValidator, + webhook: WebhookValidator }, // This adds the Errors object to the validation engine, and shouldn't affect diff --git a/ghost/admin/app/models/api-key.js b/ghost/admin/app/models/api-key.js new file mode 100644 index 0000000000..683e9cd75b --- /dev/null +++ b/ghost/admin/app/models/api-key.js @@ -0,0 +1,15 @@ +import Model from 'ember-data/model'; +import attr from 'ember-data/attr'; +import {belongsTo} from 'ember-data/relationships'; + +export default Model.extend({ + type: attr('string'), + secret: attr('string'), + lastSeenAtUTC: attr('moment-utc'), + createdAtUTC: attr('moment-utc'), + createdBy: attr('number'), + updatedAtUTC: attr('moment-utc'), + updatedBy: attr('number'), + + integration: belongsTo('integration') +}); diff --git a/ghost/admin/app/models/integration.js b/ghost/admin/app/models/integration.js new file mode 100644 index 0000000000..f5f2603f44 --- /dev/null +++ b/ghost/admin/app/models/integration.js @@ -0,0 +1,35 @@ +import Model from 'ember-data/model'; +import ValidationEngine from 'ghost-admin/mixins/validation-engine'; +import attr from 'ember-data/attr'; +import {computed} from '@ember/object'; +import {hasMany} from 'ember-data/relationships'; + +export default Model.extend(ValidationEngine, { + validationType: 'integration', + + name: attr('string'), + slug: attr('string'), + iconImage: attr('string'), + description: attr('string'), + createdAtUTC: attr('moment-utc'), + createdBy: attr('number'), + updatedAtUTC: attr('moment-utc'), + updatedBy: attr('number'), + + apiKeys: hasMany('api-key', { + embedded: 'always', + async: false + }), + webhooks: hasMany('webhook', { + embedded: 'always', + async: false + }), + + adminKey: computed('apiKeys.[]', function () { + return this.apiKeys.findBy('type', 'admin'); + }), + + contentKey: computed('apiKeys.[]', function () { + return this.apiKeys.findBy('type', 'content'); + }) +}); diff --git a/ghost/admin/app/models/webhook.js b/ghost/admin/app/models/webhook.js new file mode 100644 index 0000000000..eff39e05dc --- /dev/null +++ b/ghost/admin/app/models/webhook.js @@ -0,0 +1,20 @@ +import Model from 'ember-data/model'; +import ValidationEngine from 'ghost-admin/mixins/validation-engine'; +import attr from 'ember-data/attr'; +import {belongsTo} from 'ember-data/relationships'; + +export default Model.extend(ValidationEngine, { + validationType: 'webhook', + + name: attr('string'), + event: attr('string'), + targetUrl: attr('string'), + secret: attr('string'), + lastTriggeredAtUTC: attr('moment-utc'), + createdAtUTC: attr('moment-utc'), + createdBy: attr('number'), + updatedAtUTC: attr('moment-utc'), + updatedBy: attr('number'), + + integration: belongsTo('integration') +}); diff --git a/ghost/admin/app/router.js b/ghost/admin/app/router.js index e50d7be76a..abf18f2c57 100644 --- a/ghost/admin/app/router.js +++ b/ghost/admin/app/router.js @@ -52,8 +52,12 @@ Router.map(function () { this.route('settings.design', {path: '/settings/design'}, function () { this.route('uploadtheme'); }); - - this.route('settings.integrations', {path: '/settings/integrations'}); + this.route('settings.integrations', {path: '/settings/integrations'}, function () { + this.route('new'); + }); + this.route('settings.integration', {path: '/settings/integrations/:integration_id'}, function () { + this.route('webhooks.new', {path: 'webhooks/new'}); + }); this.route('settings.integrations.slack', {path: '/settings/integrations/slack'}); this.route('settings.integrations.amp', {path: '/settings/integrations/amp'}); this.route('settings.integrations.unsplash', {path: '/settings/integrations/unsplash'}); diff --git a/ghost/admin/app/routes/settings/integration.js b/ghost/admin/app/routes/settings/integration.js new file mode 100644 index 0000000000..27d58be47b --- /dev/null +++ b/ghost/admin/app/routes/settings/integration.js @@ -0,0 +1,56 @@ +import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; +import CurrentUserSettings from 'ghost-admin/mixins/current-user-settings'; +import styleBody from 'ghost-admin/mixins/style-body'; + +export default AuthenticatedRoute.extend(styleBody, CurrentUserSettings, { + titleToken: 'Settings - Integrations', + classNames: ['settings-view-integration'], + + beforeModel() { + this._super(...arguments); + return this.get('session.user') + .then(this.transitionAuthor()) + .then(this.transitionEditor()); + }, + + model(params, transition) { + let integration = this.store.peekRecord('integration', params.integration_id); + + if (integration) { + return integration; + } + + // integration is not already in the store so use the integrations controller + // to fetch all of them and pull out the one we're interested in. Using the + // integrations controller means it's possible to navigate back to the integrations + // screen without triggering a loading state + return this.controllerFor('settings.integrations') + .fetchIntegrations.perform() + .then((integrations) => { + let integration = integrations.findBy('id', params.integration_id); + + if (!integration) { + let path = transition.intent.url.replace(/^\//, ''); + return this.replaceWith('error404', {path, status: 404}); + } + + return integration; + }); + }, + + actions: { + save() { + this.controller.send('save'); + }, + + willTransition(transition) { + let {controller} = this; + + if (controller.integration.hasDirtyAttributes) { + transition.abort(); + controller.send('toggleUnsavedChangesModal', transition); + return; + } + } + } +}); diff --git a/ghost/admin/app/routes/settings/integration/webhooks/new.js b/ghost/admin/app/routes/settings/integration/webhooks/new.js new file mode 100644 index 0000000000..ba5fb4828c --- /dev/null +++ b/ghost/admin/app/routes/settings/integration/webhooks/new.js @@ -0,0 +1,13 @@ +import Route from '@ember/routing/route'; + +export default Route.extend({ + model() { + let integration = this.modelFor('settings.integration'); + return this.store.createRecord('webhook', {integration}); + }, + + deactivate() { + this._super(...arguments); + this.controller.webhook.rollbackAttributes(); + } +}); diff --git a/ghost/admin/app/routes/settings/integrations.js b/ghost/admin/app/routes/settings/integrations.js index e93b14c09c..d402854d32 100644 --- a/ghost/admin/app/routes/settings/integrations.js +++ b/ghost/admin/app/routes/settings/integrations.js @@ -14,5 +14,11 @@ export default AuthenticatedRoute.extend(styleBody, CurrentUserSettings, { return this.get('session.user') .then(this.transitionAuthor()) .then(this.transitionEditor()); + }, + + setupController(controller) { + // kick off the background fetch of integrations so that we can + // show the screen immediately + controller.fetchIntegrations.perform(); } }); diff --git a/ghost/admin/app/routes/settings/integrations/new.js b/ghost/admin/app/routes/settings/integrations/new.js new file mode 100644 index 0000000000..f680466045 --- /dev/null +++ b/ghost/admin/app/routes/settings/integrations/new.js @@ -0,0 +1,12 @@ +import Route from '@ember/routing/route'; + +export default Route.extend({ + model() { + return this.store.createRecord('integration'); + }, + + deactivate() { + this._super(...arguments); + this.controller.integration.rollbackAttributes(); + } +}); diff --git a/ghost/admin/app/serializers/api-key.js b/ghost/admin/app/serializers/api-key.js new file mode 100644 index 0000000000..1f35f09fe6 --- /dev/null +++ b/ghost/admin/app/serializers/api-key.js @@ -0,0 +1,9 @@ +import ApplicationSerializer from './application'; + +export default ApplicationSerializer.extend({ + attrs: { + lastSeenAtUTC: {key: 'last_seen_at'}, + createdAtUTC: {key: 'created_at'}, + updatedAtUTC: {key: 'updated_at'} + } +}); diff --git a/ghost/admin/app/serializers/integration.js b/ghost/admin/app/serializers/integration.js new file mode 100644 index 0000000000..113cf0a844 --- /dev/null +++ b/ghost/admin/app/serializers/integration.js @@ -0,0 +1,11 @@ +import ApplicationSerializer from './application'; +import EmbeddedRecordsMixin from 'ember-data/serializers/embedded-records-mixin'; + +export default ApplicationSerializer.extend(EmbeddedRecordsMixin, { + attrs: { + apiKeys: {embedded: 'always'}, + webhooks: {embedded: 'always'}, + createdAtUTC: {key: 'created_at'}, + updatedAtUTC: {key: 'updated_at'} + } +}); diff --git a/ghost/admin/app/serializers/webhook.js b/ghost/admin/app/serializers/webhook.js new file mode 100644 index 0000000000..9073a383eb --- /dev/null +++ b/ghost/admin/app/serializers/webhook.js @@ -0,0 +1,9 @@ +import ApplicationSerializer from './application'; + +export default ApplicationSerializer.extend({ + attrs: { + lastTriggeredAtUTC: {key: 'last_triggered_at'}, + createdAtUTC: {key: 'created_at'}, + updatedAtUTC: {key: 'updated_at'} + } +}); diff --git a/ghost/admin/app/styles/patterns/forms.css b/ghost/admin/app/styles/patterns/forms.css index 2ffb5c0c33..4773a15f3a 100644 --- a/ghost/admin/app/styles/patterns/forms.css +++ b/ghost/admin/app/styles/patterns/forms.css @@ -148,6 +148,7 @@ select { .gh-input.error, .error .gh-input, +.error .gh-select select, .error .ember-power-select-multiple-trigger, .gh-select.error, select.error { diff --git a/ghost/admin/app/templates/components/modal-leave-settings.hbs b/ghost/admin/app/templates/components/modal-leave-settings.hbs index 22e45d3435..0c6afe0c46 100644 --- a/ghost/admin/app/templates/components/modal-leave-settings.hbs +++ b/ghost/admin/app/templates/components/modal-leave-settings.hbs @@ -1,4 +1,4 @@ -