diff --git a/ghost/admin/app/components/modal-delete-integration.js b/ghost/admin/app/components/modal-delete-integration.js index c9154cd750..87b93c2073 100644 --- a/ghost/admin/app/components/modal-delete-integration.js +++ b/ghost/admin/app/components/modal-delete-integration.js @@ -5,6 +5,7 @@ import {task} from 'ember-concurrency'; export default ModalComponent.extend({ router: service(), + feature: service(), notifications: service(), integration: alias('model'), actions: { @@ -15,7 +16,11 @@ export default ModalComponent.extend({ deleteIntegration: task(function* () { try { yield this.confirm(); - this.router.transitionTo('integrations'); + if (this.feature.get('offers')) { + this.router.transitionTo('settings.integrations'); + } else { + this.router.transitionTo('integrations'); + } } catch (error) { this.notifications.showAPIError(error, {key: 'integration.delete.failed'}); } finally { diff --git a/ghost/admin/app/components/modal-new-integration.js b/ghost/admin/app/components/modal-new-integration.js index ce02fc8fb4..cdb7c27fcb 100644 --- a/ghost/admin/app/components/modal-new-integration.js +++ b/ghost/admin/app/components/modal-new-integration.js @@ -8,6 +8,7 @@ import {task} from 'ember-concurrency'; export default ModalComponent.extend({ router: service(), + feature: service(), errorMessage: null, @@ -29,7 +30,11 @@ export default ModalComponent.extend({ createIntegration: task(function* () { try { let integration = yield this.confirm(); - this.router.transitionTo('integration', integration); + if (this.feature.get('offers')) { + this.router.transitionTo('settings.integration', integration); + } else { + this.router.transitionTo('integration', integration); + } } catch (error) { // TODO: server-side validation errors should be serialized // properly so that errors are added to model.errors automatically diff --git a/ghost/admin/app/components/modal-webhook-form.js b/ghost/admin/app/components/modal-webhook-form.js index f7ce4469c2..6b67e09897 100644 --- a/ghost/admin/app/components/modal-webhook-form.js +++ b/ghost/admin/app/components/modal-webhook-form.js @@ -8,6 +8,7 @@ import {task} from 'ember-concurrency'; export default ModalComponent.extend({ router: service(), + feature: service(), availableEvents: null, error: null, @@ -48,7 +49,11 @@ export default ModalComponent.extend({ try { let webhook = yield this.confirm(); let integration = yield webhook.get('integration'); - this.router.transitionTo('integration', integration); + if (this.feature.get('offers')) { + this.router.transitionTo('settings.integration', integration); + } else { + this.router.transitionTo('integration', integration); + } } catch (e) { // TODO: server-side validation errors should be serialized // properly so that errors are added to model.errors automatically diff --git a/ghost/admin/app/controllers/settings/integration.js b/ghost/admin/app/controllers/settings/integration.js new file mode 100644 index 0000000000..b9adbd0551 --- /dev/null +++ b/ghost/admin/app/controllers/settings/integration.js @@ -0,0 +1,186 @@ +import Controller from '@ember/controller'; +import config from 'ghost-admin/config/environment'; +import copyTextToClipboard from 'ghost-admin/utils/copy-text-to-clipboard'; +import { + IMAGE_EXTENSIONS, + IMAGE_MIME_TYPES +} from 'ghost-admin/components/gh-image-uploader'; +import {alias} from '@ember/object/computed'; +import {computed} from '@ember/object'; +import {htmlSafe} from '@ember/template'; +import {inject as service} from '@ember/service'; +import {task, timeout} from 'ember-concurrency'; + +export default Controller.extend({ + config: service(), + ghostPaths: service(), + + imageExtensions: IMAGE_EXTENSIONS, + imageMimeTypes: IMAGE_MIME_TYPES, + showRegenerateKeyModal: false, + selectedApiKey: null, + isApiKeyRegenerated: false, + + init() { + this._super(...arguments); + if (this.isTesting === undefined) { + this.isTesting = config.environment === 'test'; + } + }, + + integration: alias('model'), + + apiUrl: computed(function () { + let origin = window.location.origin; + let subdir = this.ghostPaths.subdir; + let url = this.ghostPaths.url.join(origin, subdir); + + return url.replace(/\/$/, ''); + }), + + regeneratedKeyType: computed('isApiKeyRegenerated', 'selectedApiKey', function () { + if (this.isApiKeyRegenerated) { + return this.get('selectedApiKey.type'); + } + return null; + }), + + allWebhooks: computed(function () { + return this.store.peekAll('webhook'); + }), + + filteredWebhooks: computed('integration.id', '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; + }); + }), + + iconImageStyle: computed('integration.iconImage', function () { + let url = this.integration.iconImage; + if (url) { + let styles = [ + `background-image: url(${url})`, + 'background-size: 50%', + 'background-position: 50%', + 'background-repeat: no-repeat' + ]; + return htmlSafe(styles.join('; ')); + } + + return htmlSafe(''); + }), + + actions: { + triggerIconFileDialog() { + let input = document.querySelector('input[type="file"][name="iconImage"]'); + input.click(); + }, + + setIconImage([image]) { + this.integration.set('iconImage', image.url); + }, + + save() { + return this.save.perform(); + }, + + 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(); + }, + + deleteIntegration() { + this.integration.destroyRecord(); + }, + + confirmIntegrationDeletion() { + this.set('showDeleteIntegrationModal', true); + }, + + cancelIntegrationDeletion() { + this.set('showDeleteIntegrationModal', false); + }, + + confirmRegenerateKeyModal(apiKey) { + this.set('showRegenerateKeyModal', true); + this.set('isApiKeyRegenerated', false); + this.set('selectedApiKey', apiKey); + }, + + cancelRegenerateKeyModal() { + this.set('showRegenerateKeyModal', false); + }, + + regenerateKey() { + this.set('isApiKeyRegenerated', true); + }, + + confirmWebhookDeletion(webhook) { + this.set('webhookToDelete', webhook); + }, + + cancelWebhookDeletion() { + this.set('webhookToDelete', null); + }, + + deleteWebhook() { + return this.webhookToDelete.destroyRecord(); + } + }, + + save: task(function* () { + return yield this.integration.save(); + }), + + copyContentKey: task(function* () { + copyTextToClipboard(this.integration.contentKey.secret); + yield timeout(this.isTesting ? 50 : 3000); + }), + + copyAdminKey: task(function* () { + copyTextToClipboard(this.integration.adminKey.secret); + yield timeout(this.isTesting ? 50 : 3000); + }), + + copyApiUrl: task(function* () { + copyTextToClipboard(this.apiUrl); + yield timeout(this.isTesting ? 50 : 3000); + }) +}); diff --git a/ghost/admin/app/controllers/settings/integration/webhooks/edit.js b/ghost/admin/app/controllers/settings/integration/webhooks/edit.js new file mode 100644 index 0000000000..2336668914 --- /dev/null +++ b/ghost/admin/app/controllers/settings/integration/webhooks/edit.js @@ -0,0 +1,24 @@ +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('integration', integration); + }); + } + }, + + reset() { + this.webhook.rollbackAttributes(); + this.webhook.errors.clear(); + } +}); 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 new file mode 100644 index 0000000000..dfab1f646e --- /dev/null +++ b/ghost/admin/app/controllers/settings/integrations.js @@ -0,0 +1,57 @@ +/* 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(), + store: service(), + config: service(), + + _allIntegrations: null, + + init() { + this._super(...arguments); + this._allIntegrations = this.store.peekAll('integration'); + }, + + zapierDisabled: computed('config.hostSettings.limits', function () { + return this.config.get('hostSettings.limits.customIntegrations.disabled'); + }), + + // filter over the live query so that the list is automatically updated + // as integrations are added/removed + integrations: computed('_allIntegrations.@each.{isNew,type}', function () { + return this._allIntegrations.reject((integration) => { + return integration.isNew || integration.type !== 'custom'; + }); + }), + + // 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'); + }), + + // used by individual integration routes' `model` hooks + integrationModelHook(prop, value, route, transition) { + let preloadedIntegration = this.store.peekAll('integration').findBy(prop, value); + + if (preloadedIntegration) { + return preloadedIntegration; + } + + return this.fetchIntegrations.perform().then((integrations) => { + let integration = integrations.findBy(prop, value); + + if (!integration) { + let path = transition.intent.url.replace(/^\//, ''); + return route.replaceWith('error404', {path, status: 404}); + } + + return integration; + }); + } +}); diff --git a/ghost/admin/app/controllers/settings/integrations/amp.js b/ghost/admin/app/controllers/settings/integrations/amp.js new file mode 100644 index 0000000000..2099bc6d92 --- /dev/null +++ b/ghost/admin/app/controllers/settings/integrations/amp.js @@ -0,0 +1,70 @@ +/* eslint-disable ghost/ember/alias-model-in-controller */ +import Controller from '@ember/controller'; +import {inject as service} from '@ember/service'; +import {task} from 'ember-concurrency'; + +export default Controller.extend({ + notifications: service(), + settings: service(), + + leaveSettingsTransition: null, + + actions: { + update(value) { + this.settings.set('amp', value); + }, + + save() { + this.save.perform(); + }, + + toggleLeaveSettingsModal(transition) { + let leaveTransition = this.leaveSettingsTransition; + + if (!transition && this.showLeaveSettingsModal) { + this.set('leaveSettingsTransition', null); + this.set('showLeaveSettingsModal', false); + return; + } + + if (!leaveTransition || transition.targetName === leaveTransition.targetName) { + this.set('leaveSettingsTransition', 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('showLeaveSettingsModal', true); + } + }, + + leaveSettings() { + let transition = this.leaveSettingsTransition; + let settings = this.settings; + + 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 settings model + settings.rollbackAttributes(); + + return transition.retry(); + } + }, + + save: task(function* () { + try { + yield this.settings.validate(); + return yield this.settings.save(); + } catch (error) { + this.notifications.showAPIError(error); + throw error; + } + }).drop() +}); diff --git a/ghost/admin/app/controllers/settings/integrations/firstpromoter.js b/ghost/admin/app/controllers/settings/integrations/firstpromoter.js new file mode 100644 index 0000000000..8414c7c72d --- /dev/null +++ b/ghost/admin/app/controllers/settings/integrations/firstpromoter.js @@ -0,0 +1,70 @@ +/* eslint-disable ghost/ember/alias-model-in-controller */ +import Controller from '@ember/controller'; +import {inject as service} from '@ember/service'; +import {task} from 'ember-concurrency'; + +export default Controller.extend({ + notifications: service(), + settings: service(), + + leaveSettingsTransition: null, + + actions: { + update(value) { + this.settings.set('firstpromoter', value); + }, + + save() { + this.save.perform(); + }, + + toggleLeaveSettingsModal(transition) { + let leaveTransition = this.leaveSettingsTransition; + + if (!transition && this.showLeaveSettingsModal) { + this.set('leaveSettingsTransition', null); + this.set('showLeaveSettingsModal', false); + return; + } + + if (!leaveTransition || transition.targetName === leaveTransition.targetName) { + this.set('leaveSettingsTransition', 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('showLeaveSettingsModal', true); + } + }, + + leaveSettings() { + let transition = this.leaveSettingsTransition; + let settings = this.settings; + + 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 settings model + settings.rollbackAttributes(); + + return transition.retry(); + } + }, + + save: task(function* () { + try { + yield this.settings.validate(); + return yield this.settings.save(); + } catch (error) { + this.notifications.showAPIError(error); + throw error; + } + }).drop() +}); 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..737c3ec88e --- /dev/null +++ b/ghost/admin/app/controllers/settings/integrations/new.js @@ -0,0 +1,27 @@ +import Controller from '@ember/controller'; +import {alias} from '@ember/object/computed'; +import {computed} from '@ember/object'; + +export default Controller.extend({ + integration: alias('model.integration'), + hostLimitError: alias('model.hostLimitError'), + + showUpgradeModal: computed('hostLimitError', function () { + if (this.hostLimitError) { + return true; + } + + return false; + }), + + 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/controllers/settings/integrations/slack.js b/ghost/admin/app/controllers/settings/integrations/slack.js new file mode 100644 index 0000000000..ca014d1117 --- /dev/null +++ b/ghost/admin/app/controllers/settings/integrations/slack.js @@ -0,0 +1,133 @@ +/* eslint-disable ghost/ember/alias-model-in-controller */ +import Controller from '@ember/controller'; +import boundOneWay from 'ghost-admin/utils/bound-one-way'; +import {empty} from '@ember/object/computed'; +import {isInvalidError} from 'ember-ajax/errors'; +import {inject as service} from '@ember/service'; +import {task} from 'ember-concurrency'; + +export default Controller.extend({ + ghostPaths: service(), + ajax: service(), + notifications: service(), + settings: service(), + + leaveSettingsTransition: null, + slackArray: null, + + init() { + this._super(...arguments); + this.slackArray = []; + }, + + slackSettings: boundOneWay('settings.slack.firstObject'), + testNotificationDisabled: empty('slackSettings.url'), + + actions: { + save() { + this.save.perform(); + }, + + updateURL(value) { + value = typeof value === 'string' ? value.trim() : value; + this.set('slackSettings.url', value); + this.get('slackSettings.errors').clear(); + }, + + updateUsername(value) { + value = typeof value === 'string' ? value.trimLeft() : value; + this.set('slackSettings.username', value); + this.get('slackSettings.errors').clear(); + }, + + triggerDirtyState() { + let slack = this.slackSettings; + let slackArray = this.slackArray; + let settings = this.settings; + + // Hack to trigger the `isDirty` state on the settings model by setting a new Array + // for slack rather that replacing the existing one which would still point to the + // same reference and therfore not setting the model into a dirty state + slackArray.clear().pushObject(slack); + settings.set('slack', slackArray); + }, + + toggleLeaveSettingsModal(transition) { + let leaveTransition = this.leaveSettingsTransition; + + if (!transition && this.showLeaveSettingsModal) { + this.set('leaveSettingsTransition', null); + this.set('showLeaveSettingsModal', false); + return; + } + + if (!leaveTransition || transition.targetName === leaveTransition.targetName) { + this.set('leaveSettingsTransition', 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('showLeaveSettingsModal', true); + } + }, + + leaveSettings() { + let transition = this.leaveSettingsTransition; + let settings = this.settings; + let slackArray = this.slackArray; + + 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 + settings.rollbackAttributes(); + slackArray.clear(); + + return transition.retry(); + } + }, + + save: task(function* () { + let slack = this.slackSettings; + let settings = this.settings; + let slackArray = this.slackArray; + + try { + yield slack.validate(); + // clear existing objects in slackArray to make sure we only push the validated one + slackArray.clear().pushObject(slack); + yield settings.set('slack', slackArray); + return yield settings.save(); + } catch (error) { + if (error) { + this.notifications.showAPIError(error); + throw error; + } + } + }).drop(), + + sendTestNotification: task(function* () { + let notifications = this.notifications; + let slackApi = this.get('ghostPaths.url').api('slack', 'test'); + + try { + yield this.save.perform(); + yield this.ajax.post(slackApi); + notifications.showNotification('Test notification sent', {type: 'info', key: 'slack-test.send.success', description: 'Check your Slack channel for the test message'}); + return true; + } catch (error) { + notifications.showAPIError(error, {key: 'slack-test:send'}); + + if (!isInvalidError(error)) { + throw error; + } + } + }).drop() +}); diff --git a/ghost/admin/app/controllers/settings/integrations/unsplash.js b/ghost/admin/app/controllers/settings/integrations/unsplash.js new file mode 100644 index 0000000000..5fd21473a0 --- /dev/null +++ b/ghost/admin/app/controllers/settings/integrations/unsplash.js @@ -0,0 +1,70 @@ +/* eslint-disable ghost/ember/alias-model-in-controller */ +import Controller from '@ember/controller'; +import {inject as service} from '@ember/service'; +import {task} from 'ember-concurrency'; + +export default Controller.extend({ + notifications: service(), + settings: service(), + + leaveSettingsTransition: null, + + actions: { + update(value) { + this.settings.set('unsplash', value); + }, + + save() { + this.save.perform(); + }, + + toggleLeaveSettingsModal(transition) { + let leaveTransition = this.leaveSettingsTransition; + + if (!transition && this.showLeaveSettingsModal) { + this.set('leaveSettingsTransition', null); + this.set('showLeaveSettingsModal', false); + return; + } + + if (!leaveTransition || transition.targetName === leaveTransition.targetName) { + this.set('leaveSettingsTransition', 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('showLeaveSettingsModal', true); + } + }, + + leaveSettings() { + let transition = this.leaveSettingsTransition; + let settings = this.settings; + + 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 settings model + settings.rollbackAttributes(); + + return transition.retry(); + } + }, + + save: task(function* () { + try { + yield this.settings.validate(); + return yield this.settings.save(); + } catch (error) { + this.notifications.showAPIError(error); + throw error; + } + }).drop() +}); diff --git a/ghost/admin/app/controllers/settings/integrations/zapier.js b/ghost/admin/app/controllers/settings/integrations/zapier.js new file mode 100644 index 0000000000..4eaee56f48 --- /dev/null +++ b/ghost/admin/app/controllers/settings/integrations/zapier.js @@ -0,0 +1,65 @@ +/* eslint-disable ghost/ember/alias-model-in-controller */ +import Controller from '@ember/controller'; +import config from 'ghost-admin/config/environment'; +import copyTextToClipboard from 'ghost-admin/utils/copy-text-to-clipboard'; +import {alias} from '@ember/object/computed'; +import {computed} from '@ember/object'; +import {inject as service} from '@ember/service'; +import {task, timeout} from 'ember-concurrency'; + +export default Controller.extend({ + ghostPaths: service(), + + selectedApiKey: null, + isApiKeyRegenerated: false, + + init() { + this._super(...arguments); + if (this.isTesting === undefined) { + this.isTesting = config.environment === 'test'; + } + }, + + integration: alias('model'), + + apiUrl: computed(function () { + let origin = window.location.origin; + let subdir = this.ghostPaths.subdir; + let url = this.ghostPaths.url.join(origin, subdir); + + return url.replace(/\/$/, ''); + }), + + regeneratedKeyType: computed('isApiKeyRegenerated', 'selectedApiKey', function () { + if (this.isApiKeyRegenerated) { + return this.get('selectedApiKey.type'); + } + return null; + }), + + actions: { + confirmRegenerateKeyModal(apiKey) { + this.set('showRegenerateKeyModal', true); + this.set('isApiKeyRegenerated', false); + this.set('selectedApiKey', apiKey); + }, + + cancelRegenerateKeyModal() { + this.set('showRegenerateKeyModal', false); + }, + + regenerateKey() { + this.set('isApiKeyRegenerated', true); + } + }, + + copyAdminKey: task(function* () { + copyTextToClipboard(this.integration.adminKey.secret); + yield timeout(this.isTesting ? 50 : 3000); + }), + + copyApiUrl: task(function* () { + copyTextToClipboard(this.apiUrl); + yield timeout(this.isTesting ? 50 : 3000); + }) +}); diff --git a/ghost/admin/app/router.js b/ghost/admin/app/router.js index 09d4a2891e..b59ea5edd8 100644 --- a/ghost/admin/app/router.js +++ b/ghost/admin/app/router.js @@ -58,6 +58,19 @@ Router.map(function () { this.route('user', {path: ':user_slug'}); }); + 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('webhooks.edit', {path: 'webhooks/:webhook_id'}); + }); + this.route('settings.integrations.slack', {path: '/settings/integrations/slack'}); + this.route('settings.integrations.amp', {path: '/settings/integrations/amp'}); + this.route('settings.integrations.firstpromoter', {path: '/settings/integrations/firstpromoter'}); + this.route('settings.integrations.unsplash', {path: '/settings/integrations/unsplash'}); + this.route('settings.integrations.zapier', {path: '/settings/integrations/zapier'}); + this.route('settings.theme', {path: '/settings/theme'}, function () { this.route('uploadtheme'); this.route('install'); diff --git a/ghost/admin/app/routes/integrations/zapier.js b/ghost/admin/app/routes/integrations/zapier.js index fa64d009c9..62d9fafa64 100644 --- a/ghost/admin/app/routes/integrations/zapier.js +++ b/ghost/admin/app/routes/integrations/zapier.js @@ -6,6 +6,7 @@ import {inject as service} from '@ember/service'; export default AuthenticatedRoute.extend(CurrentUserSettings, { router: service(), config: service(), + feature: service(), init() { this._super(...arguments); @@ -40,7 +41,11 @@ export default AuthenticatedRoute.extend(CurrentUserSettings, { transitionDisabled() { if (this.get('disabled')) { - this.transitionTo('integrations'); + if (this.feature.get('offers')) { + this.transitionTo('settings.integrations'); + } else { + this.transitionTo('integrations'); + } } }, diff --git a/ghost/admin/app/routes/settings/integration.js b/ghost/admin/app/routes/settings/integration.js new file mode 100644 index 0000000000..cf807a6eba --- /dev/null +++ b/ghost/admin/app/routes/settings/integration.js @@ -0,0 +1,73 @@ +import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; +import CurrentUserSettings from 'ghost-admin/mixins/current-user-settings'; +import {inject as service} from '@ember/service'; + +export default AuthenticatedRoute.extend(CurrentUserSettings, { + router: service(), + + init() { + this._super(...arguments); + this.router.on('routeWillChange', (transition) => { + this.showUnsavedChangesModal(transition); + if (this.controller) { + this.controller.set('selectedApiKey', null); + this.controller.set('isApiKeyRegenerated', false); + } + }); + }, + + beforeModel() { + this._super(...arguments); + this.transitionAuthor(this.session.user); + this.transitionEditor(this.session.user); + }, + + model(params, transition) { + // use the integrations controller to fetch all integrations and pick + // out the one we want. Allows navigation back to integrations screen + // without a loading state + return this + .controllerFor('settings.integrations') + .integrationModelHook('id', params.integration_id, this, transition); + }, + + deactivate() { + this._super(...arguments); + this.controller.set('leaveScreenTransition', null); + this.controller.set('showUnsavedChangesModal', false); + }, + + actions: { + save() { + this.controller.send('save'); + } + }, + + showUnsavedChangesModal(transition) { + if (transition.from && transition.from.name.match(/^settings\.integration\./) && transition.targetName) { + let {controller} = this; + + // check to see if we're navigating away from the custom integration + // route - we want to allow editing webhooks without showing the + // "unsaved changes" confirmation modal + let isExternalRoute = + // allow sub-routes of integration + !(transition.targetName || '').match(/^integration\./) + // do not allow changes in integration + // .to will be the index, so use .to.parent to get the route with the params + || transition.to.parent.params.integration_id !== controller.integration.id; + + if (isExternalRoute && !controller.integration.isDeleted && controller.integration.hasDirtyAttributes) { + transition.abort(); + controller.send('toggleUnsavedChangesModal', transition); + return; + } + } + }, + + buildRouteInfoMetadata() { + return { + titleToken: 'Settings - Integrations' + }; + } +}); diff --git a/ghost/admin/app/routes/settings/integration/webhooks/edit.js b/ghost/admin/app/routes/settings/integration/webhooks/edit.js new file mode 100644 index 0000000000..2733a90719 --- /dev/null +++ b/ghost/admin/app/routes/settings/integration/webhooks/edit.js @@ -0,0 +1,14 @@ +import Route from '@ember/routing/route'; + +export default Route.extend({ + model(params) { + let integration = this.modelFor('settings.integration'); + let webhook = integration.webhooks.findBy('id', params.webhook_id); + return webhook; + }, + + deactivate() { + this._super(...arguments); + this.controller.reset(); + } +}); 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 new file mode 100644 index 0000000000..9c766e983f --- /dev/null +++ b/ghost/admin/app/routes/settings/integrations.js @@ -0,0 +1,25 @@ +import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; +import CurrentUserSettings from 'ghost-admin/mixins/current-user-settings'; +import {inject as service} from '@ember/service'; + +export default AuthenticatedRoute.extend(CurrentUserSettings, { + settings: service(), + + beforeModel() { + this._super(...arguments); + this.transitionAuthor(this.session.user); + this.transitionEditor(this.session.user); + }, + + setupController(controller) { + // kick off the background fetch of integrations so that we can + // show the screen immediately + controller.fetchIntegrations.perform(); + }, + + buildRouteInfoMetadata() { + return { + titleToken: 'Settings - Integrations' + }; + } +}); diff --git a/ghost/admin/app/routes/settings/integrations/amp.js b/ghost/admin/app/routes/settings/integrations/amp.js new file mode 100644 index 0000000000..ed7c9e246f --- /dev/null +++ b/ghost/admin/app/routes/settings/integrations/amp.js @@ -0,0 +1,39 @@ +import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; +import CurrentUserSettings from 'ghost-admin/mixins/current-user-settings'; +import {inject as service} from '@ember/service'; + +export default AuthenticatedRoute.extend(CurrentUserSettings, { + settings: service(), + + beforeModel() { + this._super(...arguments); + this.transitionAuthor(this.session.user); + this.transitionEditor(this.session.user); + + return this.settings.reload(); + }, + + actions: { + save() { + this.controller.send('save'); + }, + + willTransition(transition) { + let controller = this.controller; + let modelIsDirty = this.settings.get('hasDirtyAttributes'); + + if (modelIsDirty) { + transition.abort(); + controller.send('toggleLeaveSettingsModal', transition); + return; + } + } + }, + + buildRouteInfoMetadata() { + return { + titleToken: 'AMP' + }; + } + +}); diff --git a/ghost/admin/app/routes/settings/integrations/firstpromoter.js b/ghost/admin/app/routes/settings/integrations/firstpromoter.js new file mode 100644 index 0000000000..492b6bb01d --- /dev/null +++ b/ghost/admin/app/routes/settings/integrations/firstpromoter.js @@ -0,0 +1,39 @@ +import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; +import CurrentUserSettings from 'ghost-admin/mixins/current-user-settings'; +import {inject as service} from '@ember/service'; + +export default AuthenticatedRoute.extend(CurrentUserSettings, { + settings: service(), + + beforeModel() { + this._super(...arguments); + this.transitionAuthor(this.session.user); + this.transitionEditor(this.session.user); + + return this.settings.reload(); + }, + + actions: { + save() { + this.controller.send('save'); + }, + + willTransition(transition) { + let controller = this.controller; + let modelIsDirty = this.settings.get('hasDirtyAttributes'); + + if (modelIsDirty) { + transition.abort(); + controller.send('toggleLeaveSettingsModal', transition); + return; + } + } + }, + + buildRouteInfoMetadata() { + return { + titleToken: 'FirstPromoter' + }; + } + +}); 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..507eea8b20 --- /dev/null +++ b/ghost/admin/app/routes/settings/integrations/new.js @@ -0,0 +1,31 @@ +import RSVP from 'rsvp'; +import Route from '@ember/routing/route'; +import {inject as service} from '@ember/service'; + +export default Route.extend({ + limit: service(), + + model() { + if (this.limit.limiter + && this.limit.limiter.isLimited('customIntegrations')) { + return RSVP.hash({ + integration: this.store.createRecord('integration'), + hostLimitError: this.limit.limiter.errorIfWouldGoOverLimit('customIntegrations') + .then(() => null) + .catch((error) => { + return error; + }) + }); + } else { + return RSVP.hash({ + integration: this.store.createRecord('integration'), + hostLimitError: null + }); + } + }, + + deactivate() { + this._super(...arguments); + this.controller.integration.rollbackAttributes(); + } +}); diff --git a/ghost/admin/app/routes/settings/integrations/slack.js b/ghost/admin/app/routes/settings/integrations/slack.js new file mode 100644 index 0000000000..3c1ed138b0 --- /dev/null +++ b/ghost/admin/app/routes/settings/integrations/slack.js @@ -0,0 +1,39 @@ +import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; +import CurrentUserSettings from 'ghost-admin/mixins/current-user-settings'; +import {inject as service} from '@ember/service'; + +export default AuthenticatedRoute.extend(CurrentUserSettings, { + settings: service(), + + beforeModel() { + this._super(...arguments); + this.transitionAuthor(this.session.user); + this.transitionEditor(this.session.user); + + return this.settings.reload(); + }, + + actions: { + save() { + this.controller.send('save'); + }, + + willTransition(transition) { + let controller = this.controller; + let settings = this.settings; + let modelIsDirty = settings.get('hasDirtyAttributes'); + + if (modelIsDirty) { + transition.abort(); + controller.send('toggleLeaveSettingsModal', transition); + return; + } + } + }, + + buildRouteInfoMetadata() { + return { + titleToken: 'Slack' + }; + } +}); diff --git a/ghost/admin/app/routes/settings/integrations/unsplash.js b/ghost/admin/app/routes/settings/integrations/unsplash.js new file mode 100644 index 0000000000..9581c28e8c --- /dev/null +++ b/ghost/admin/app/routes/settings/integrations/unsplash.js @@ -0,0 +1,38 @@ +import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; +import CurrentUserSettings from 'ghost-admin/mixins/current-user-settings'; +import {inject as service} from '@ember/service'; + +export default AuthenticatedRoute.extend(CurrentUserSettings, { + settings: service(), + + beforeModel() { + this._super(...arguments); + this.transitionAuthor(this.session.user); + this.transitionEditor(this.session.user); + + return this.settings.reload(); + }, + + actions: { + save() { + this.controller.send('save'); + }, + + willTransition(transition) { + let controller = this.controller; + let modelIsDirty = this.settings.get('hasDirtyAttributes'); + + if (modelIsDirty) { + transition.abort(); + controller.send('toggleLeaveSettingsModal', transition); + return; + } + } + }, + + buildRouteInfoMetadata() { + return { + titleToken: 'Unsplash' + }; + } +}); diff --git a/ghost/admin/app/routes/settings/integrations/zapier.js b/ghost/admin/app/routes/settings/integrations/zapier.js new file mode 100644 index 0000000000..523bc201a7 --- /dev/null +++ b/ghost/admin/app/routes/settings/integrations/zapier.js @@ -0,0 +1,52 @@ +import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; +import CurrentUserSettings from 'ghost-admin/mixins/current-user-settings'; +import {computed} from '@ember/object'; +import {inject as service} from '@ember/service'; + +export default AuthenticatedRoute.extend(CurrentUserSettings, { + router: service(), + config: service(), + + init() { + this._super(...arguments); + this.router.on('routeWillChange', () => { + if (this.controller) { + this.controller.set('selectedApiKey', null); + this.controller.set('isApiKeyRegenerated', false); + } + }); + }, + + disabled: computed('config.hostSettings.limits', function () { + return this.config.get('hostSettings.limits.customIntegrations.disabled'); + }), + + beforeModel() { + this._super(...arguments); + + this.transitionDisabled(); + this.transitionAuthor(this.session.user); + this.transitionEditor(this.session.user); + }, + + model(params, transition) { + // use the integrations controller to fetch all integrations and pick + // out the one we want. Allows navigation back to integrations screen + // without a loading state + return this + .controllerFor('integrations') + .integrationModelHook('slug', 'zapier', this, transition); + }, + + transitionDisabled() { + if (this.get('disabled')) { + this.transitionTo('settings.integrations'); + } + }, + + buildRouteInfoMetadata() { + return { + titleToken: 'Zapier' + }; + } +}); diff --git a/ghost/admin/app/templates/settings.hbs b/ghost/admin/app/templates/settings.hbs index d55e9a6525..596d6726f4 100644 --- a/ghost/admin/app/templates/settings.hbs +++ b/ghost/admin/app/templates/settings.hbs @@ -79,7 +79,7 @@
Name | +Event | +URL | +Last triggered | ++ |
---|---|---|---|---|
+
+
+ + No webhooks configured + +
+ {{svg-jar "add" class="w3 h3 fill-green-d1"}}
+ Add webhook
+
+ |
+ ||||
+
+ {{svg-jar "add" class="w3 h3 fill-green-d1"}}
+ Add webhook
+
+ |
+
+ Create your own custom Ghost integrations with dedicated API keys & webhooks +
+Accelerated Mobile Pages
+Launch your own member referral program
+A messaging app for teams
+Beautiful, free photos
+Automation for your favorite apps
+ +Explore pre-built templates for common automation tasks
+