diff --git a/app/assets/javascripts/admin/components/admin-web-hook-event-chooser.js.es6 b/app/assets/javascripts/admin/components/admin-web-hook-event-chooser.js.es6 new file mode 100644 index 000000000..976b48399 --- /dev/null +++ b/app/assets/javascripts/admin/components/admin-web-hook-event-chooser.js.es6 @@ -0,0 +1,42 @@ +import computed from 'ember-addons/ember-computed-decorators'; + +export default Ember.Component.extend({ + classNames: ['hook-event'], + typeName: Ember.computed.alias('type.name'), + + @computed('typeName') + name(typeName) { + return I18n.t(`admin.web_hooks.${typeName}_event.name`); + }, + + @computed('typeName') + details(typeName) { + return I18n.t(`admin.web_hooks.${typeName}_event.details`); + }, + + @computed('model.[]', 'typeName') + eventTypeExists(eventTypes, typeName) { + return eventTypes.any(event => event.name === typeName); + }, + + @computed('eventTypeExists') + enabled: { + get(eventTypeExists) { + return eventTypeExists; + }, + set(value, eventTypeExists) { + const type = this.get('type'); + const model = this.get('model'); + // add an association when not exists + if (value !== eventTypeExists) { + if (value) { + model.addObject(type); + } else { + model.removeObjects(model.filter(eventType => eventType.name === type.name)); + } + } + + return value; + } + } +}); diff --git a/app/assets/javascripts/admin/components/admin-web-hook-event.js.es6 b/app/assets/javascripts/admin/components/admin-web-hook-event.js.es6 new file mode 100644 index 000000000..3c27f644b --- /dev/null +++ b/app/assets/javascripts/admin/components/admin-web-hook-event.js.es6 @@ -0,0 +1,78 @@ +import computed from 'ember-addons/ember-computed-decorators'; +import { ajax } from 'discourse/lib/ajax'; +import { popupAjaxError } from 'discourse/lib/ajax-error'; +import { ensureJSON, plainJSON, prettyJSON } from 'discourse/lib/formatter'; + +export default Ember.Component.extend({ + tagName: 'li', + expandDetails: null, + + @computed('model.status') + statusColorClasses(status) { + if (!status) return ''; + + if (status >= 200 && status <= 299) { + return 'text-successful'; + } else { + return 'text-danger'; + } + }, + + @computed('model.created_at') + createdAt(createdAt) { + return moment(createdAt).format('YYYY-MM-DD HH:mm:ss'); + }, + + @computed('model.duration') + completion(duration) { + const seconds = Math.floor(duration / 10.0) / 100.0; + return I18n.t('admin.web_hooks.events.completion', { seconds }); + }, + + actions: { + redeliver() { + return bootbox.confirm(I18n.t('admin.web_hooks.events.redeliver_confirm'), I18n.t('no_value'), I18n.t('yes_value'), result => { + if (result) { + ajax(`/admin/web_hooks/${this.get('model.web_hook_id')}/events/${this.get('model.id')}/redeliver`, { type: 'POST' }).then(json => { + this.set('model', json.web_hook_event); + }).catch(popupAjaxError); + } + }); + }, + + toggleRequest() { + const expandDetailsKey = 'request'; + + if (this.get('expandDetails') !== expandDetailsKey) { + let headers = _.extend({ + 'Request URL': this.get('model.request_url'), + 'Request method': 'POST' + }, ensureJSON(this.get('model.headers'))); + this.setProperties({ + headers: plainJSON(headers), + body: prettyJSON(this.get('model.payload')), + expandDetails: expandDetailsKey, + bodyLabel: I18n.t('admin.web_hooks.events.payload') + }); + } else { + this.set('expandDetails', null); + } + }, + + toggleResponse() { + const expandDetailsKey = 'response'; + + if (this.get('expandDetails') !== expandDetailsKey) { + this.setProperties({ + headers: plainJSON(this.get('model.response_headers')), + body: this.get('model.response_body'), + expandDetails: expandDetailsKey, + bodyLabel: I18n.t('admin.web_hooks.events.body') + }); + } else { + this.set('expandDetails', null); + } + } + } + +}); diff --git a/app/assets/javascripts/admin/components/admin-web-hook-status.js.es6 b/app/assets/javascripts/admin/components/admin-web-hook-status.js.es6 new file mode 100644 index 000000000..ee2b38c62 --- /dev/null +++ b/app/assets/javascripts/admin/components/admin-web-hook-status.js.es6 @@ -0,0 +1,28 @@ +import computed from 'ember-addons/ember-computed-decorators'; +import StringBuffer from 'discourse/mixins/string-buffer'; +import { iconHTML } from 'discourse/helpers/fa-icon'; + +export default Ember.Component.extend(StringBuffer, { + classes: ["text-muted", "text-danger", "text-successful"], + icons: ["circle-o", "times-circle", "circle"], + + @computed('deliveryStatuses', 'model.last_delivery_status') + status(deliveryStatuses, lastDeliveryStatus) { + return deliveryStatuses.find(s => s.id === lastDeliveryStatus); + }, + + @computed('status.id', 'icons') + icon(statusId, icons) { + return icons[statusId - 1]; + }, + + @computed('status.id', 'classes') + class(statusId, classes) { + return classes[statusId - 1]; + }, + + renderString(buffer) { + buffer.push(iconHTML(this.get('icon'), { class: this.get('class') })); + buffer.push(I18n.t(`admin.web_hooks.delivery_status.${this.get('status.name')}`)); + } +}); diff --git a/app/assets/javascripts/admin/controllers/admin-web-hooks-show-events.js.es6 b/app/assets/javascripts/admin/controllers/admin-web-hooks-show-events.js.es6 new file mode 100644 index 000000000..9b4cc91f5 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin-web-hooks-show-events.js.es6 @@ -0,0 +1,14 @@ +import { ajax } from 'discourse/lib/ajax'; +import { popupAjaxError } from 'discourse/lib/ajax-error'; + +export default Ember.Controller.extend({ + actions: { + loadMore() { + this.get('model').loadMore(); + }, + + ping() { + ajax(`/admin/web_hooks/${this.get('model.extras.web_hook_id')}/ping`, {type: 'POST'}).catch(popupAjaxError); + } + } +}); diff --git a/app/assets/javascripts/admin/controllers/admin-web-hooks-show.js.es6 b/app/assets/javascripts/admin/controllers/admin-web-hooks-show.js.es6 new file mode 100644 index 000000000..7cb76be26 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin-web-hooks-show.js.es6 @@ -0,0 +1,98 @@ +import { popupAjaxError } from 'discourse/lib/ajax-error'; +import { extractDomainFromUrl } from 'discourse/lib/utilities'; +import computed from 'ember-addons/ember-computed-decorators'; +import InputValidation from 'discourse/models/input-validation'; + +export default Ember.Controller.extend({ + needs: ['adminWebHooks'], + eventTypes: Em.computed.alias('controllers.adminWebHooks.eventTypes'), + defaultEventTypes: Em.computed.alias('controllers.adminWebHooks.defaultEventTypes'), + contentTypes: Em.computed.alias('controllers.adminWebHooks.contentTypes'), + + @computed('model.isSaving', 'saved', 'saveButtonDisabled') + savingStatus(isSaving, saved, saveButtonDisabled) { + if (isSaving) { + return I18n.t('saving'); + } else if (!saveButtonDisabled && saved) { + return I18n.t('saved'); + } + // Use side effect of validation to clear saved text + this.set('saved', false); + return ''; + }, + + @computed('model.isNew') + saveButtonText(isNew) { + return isNew ? I18n.t('admin.web_hooks.create') : I18n.t('admin.web_hooks.save'); + }, + + @computed('model.secret') + secretValidation(secret) { + if (!Ember.isEmpty(secret)) { + if (secret.indexOf(' ') !== -1) { + return InputValidation.create({ + failed: true, + reason: I18n.t('admin.web_hooks.secret_invalid') + }); + } + + if (secret.length < 12) { + return InputValidation.create({ + failed: true, + reason: I18n.t('admin.web_hooks.secret_too_short') + }); + } + } + }, + + @computed('model.wildcard_web_hook', 'model.web_hook_event_types.[]') + eventTypeValidation(isWildcard, eventTypes) { + if (!isWildcard && Ember.isEmpty(eventTypes)) { + return InputValidation.create({ + failed: true, + reason: I18n.t('admin.web_hooks.event_type_missing') + }); + } + }, + + @computed('model.isSaving', 'secretValidation', 'eventTypeValidation') + saveButtonDisabled(isSaving, secretValidation, eventTypeValidation) { + return isSaving ? false : secretValidation || eventTypeValidation; + }, + + actions: { + save() { + this.set('saved', false); + const url = extractDomainFromUrl(this.get('model.payload_url')); + const model = this.get('model'); + const saveWebHook = () => { + return model.save().then(() => { + this.set('saved', true); + this.get('controllers.adminWebHooks').get('model').addObject(model); + }).catch(popupAjaxError); + }; + + if (url === 'localhost' || url.match(/192\.168\.\d+\.\d+/) || url.match(/127\.\d+\.\d+\.\d+/) || url === Discourse.BaseUrl) { + return bootbox.confirm(I18n.t('admin.web_hooks.warn_local_payload_url'), I18n.t('no_value'), I18n.t('yes_value'), result => { + if (result) { + return saveWebHook(); + } + }); + } + + return saveWebHook(); + }, + + destroy() { + return bootbox.confirm(I18n.t('admin.web_hooks.delete_confirm'), I18n.t('no_value'), I18n.t('yes_value'), result => { + if (result) { + const model = this.get('model'); + model.destroyRecord().then(() => { + this.get('controllers.adminWebHooks').get('model').removeObject(model); + this.transitionToRoute('adminWebHooks'); + }).catch(popupAjaxError); + } + }); + } + } +}); diff --git a/app/assets/javascripts/admin/controllers/admin-web-hooks.js.es6 b/app/assets/javascripts/admin/controllers/admin-web-hooks.js.es6 new file mode 100644 index 000000000..50b8b370f --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin-web-hooks.js.es6 @@ -0,0 +1,19 @@ +import { popupAjaxError } from 'discourse/lib/ajax-error'; + +export default Ember.Controller.extend({ + actions: { + destroy(webhook) { + return bootbox.confirm(I18n.t('admin.web_hooks.delete_confirm'), I18n.t('no_value'), I18n.t('yes_value'), result => { + if (result) { + webhook.destroyRecord().then(() => { + this.get('model').removeObject(webhook); + }).catch(popupAjaxError); + } + }); + }, + + loadMore() { + this.get('model').loadMore(); + } + } +}); diff --git a/app/assets/javascripts/admin/models/web-hook.js.es6 b/app/assets/javascripts/admin/models/web-hook.js.es6 new file mode 100644 index 000000000..e688a4dd0 --- /dev/null +++ b/app/assets/javascripts/admin/models/web-hook.js.es6 @@ -0,0 +1,85 @@ +import RestModel from 'discourse/models/rest'; +import Category from 'discourse/models/category'; +import Group from 'discourse/models/group'; +import { default as computed, observes } from 'ember-addons/ember-computed-decorators'; + +export default RestModel.extend({ + content_type: 1, // json + last_delivery_status: 1, // inactive + wildcard_web_hook: false, + verify_certificate: true, + active: false, + web_hook_event_types: null, + categoriesFilter: null, + groupsFilterInName: null, + + @computed('wildcard_web_hook') + webHookType: { + get(wildcard) { + return wildcard ? 'wildcard' : 'individual'; + }, + set(value) { + this.set('wildcard_web_hook', value === 'wildcard'); + } + }, + + @observes('category_ids') + updateCategoriesFilter() { + this.set('categoriesFilter', Category.findByIds(this.get('category_ids'))); + }, + + @observes('group_ids') + updateGroupsFilter() { + const groupIds = this.get('group_ids'); + this.set('groupsFilterInName', Discourse.Site.currentProp('groups').reduce((groupNames, g) => { + if (groupIds.includes(g.id)) { groupNames.push(g.name); } + return groupNames; + }, [])); + }, + + groupFinder(term) { + return Group.findAll({search: term, ignore_automatic: false}); + }, + + @computed('wildcard_web_hook', 'web_hook_event_types.[]') + description(isWildcardWebHook, types) { + let desc = ''; + + types.forEach(type => { + const name = `${type.name.toLowerCase()}_event`; + desc += (desc !== '' ? `, ${name}` : name); + }); + + return (isWildcardWebHook ? '*' : desc); + }, + + createProperties() { + const types = this.get('web_hook_event_types'); + const categories = this.get('categoriesFilter'); + // Hack as {{group-selector}} accepts a comma-separated string as data source, but + // we use an array to populate the datasource above. + const groupsFilter = this.get('groupsFilterInName'); + const groupNames = typeof groupsFilter === 'string' ? groupsFilter.split(',') : groupsFilter; + + return { + payload_url: this.get('payload_url'), + content_type: this.get('content_type'), + secret: this.get('secret'), + wildcard_web_hook: this.get('wildcard_web_hook'), + verify_certificate: this.get('verify_certificate'), + active: this.get('active'), + web_hook_event_type_ids: Ember.isEmpty(types) ? [null] : types.map(type => type.id), + category_ids: Ember.isEmpty(categories) ? [null] : categories.map(c => c.id), + group_ids: Ember.isEmpty(groupNames) || Ember.isEmpty(groupNames[0]) ? [null] : Discourse.Site.currentProp('groups') + .reduce((groupIds, g) => { + if (groupNames.includes(g.name)) { groupIds.push(g.id); } + return groupIds; + }, []) + }; + }, + + updateProperties() { + return this.createProperties(); + } +}); + diff --git a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 index 8c1f988af..df27e2c07 100644 --- a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 @@ -36,6 +36,10 @@ export default { }); }); this.route('api'); + this.resource('adminWebHooks', { path: '/web_hooks' }, function() { + this.route('show', { path: '/:web_hook_id' }); + this.route('showEvents', { path: '/:web_hook_id/events' }); + }); this.resource('admin.backups', { path: '/backups' }, function() { this.route('logs'); diff --git a/app/assets/javascripts/admin/routes/admin-web-hooks-show-events.js.es6 b/app/assets/javascripts/admin/routes/admin-web-hooks-show-events.js.es6 new file mode 100644 index 000000000..ea4e33ac6 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-web-hooks-show-events.js.es6 @@ -0,0 +1,13 @@ +export default Discourse.Route.extend({ + model(params) { + return this.store.findAll('web-hook-event', Ember.get(params, 'web_hook_id')); + }, + + setupController(controller, model) { + controller.set('model', model); + }, + + renderTemplate() { + this.render('admin/templates/web-hooks-show-events', { into: 'admin' }); + } +}); diff --git a/app/assets/javascripts/admin/routes/admin-web-hooks-show.js.es6 b/app/assets/javascripts/admin/routes/admin-web-hooks-show.js.es6 new file mode 100644 index 000000000..533d7c09f --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-web-hooks-show.js.es6 @@ -0,0 +1,26 @@ +export default Discourse.Route.extend({ + serialize(model) { + return { web_hook_id: model.get('id') || 'new' }; + }, + + model(params) { + if (params.web_hook_id === 'new') { + return this.store.createRecord('web-hook'); + } + return this.store.find('web-hook', Ember.get(params, 'web_hook_id')); + }, + + setupController(controller, model) { + if (model.get('isNew') || Ember.isEmpty(model.get('web_hook_event_types'))) { + model.set('web_hook_event_types', controller.get('defaultEventTypes')); + } + + model.set('category_ids', model.get('category_ids')); + model.set('group_ids', model.get('group_ids')); + controller.setProperties({ model, saved: false }); + }, + + renderTemplate() { + this.render('admin/templates/web-hooks-show', { into: 'admin' }); + } +}); diff --git a/app/assets/javascripts/admin/routes/admin-web-hooks.js.es6 b/app/assets/javascripts/admin/routes/admin-web-hooks.js.es6 new file mode 100644 index 000000000..69d7b4bd2 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-web-hooks.js.es6 @@ -0,0 +1,15 @@ +export default Ember.Route.extend({ + model() { + return this.store.findAll('web-hook'); + }, + + setupController(controller, model) { + controller.setProperties({ + model, + eventTypes: model.extras.event_types, + defaultEventTypes: model.extras.default_event_types, + contentTypes: model.extras.content_types, + deliveryStatuses: model.extras.delivery_statuses + }); + } +}); diff --git a/app/assets/javascripts/admin/templates/admin.hbs b/app/assets/javascripts/admin/templates/admin.hbs index 13afd577f..05076b740 100644 --- a/app/assets/javascripts/admin/templates/admin.hbs +++ b/app/assets/javascripts/admin/templates/admin.hbs @@ -20,6 +20,7 @@ {{#if currentUser.admin}} {{nav-item route='adminCustomize' label='admin.customize.title'}} {{nav-item route='admin.api' label='admin.api.title'}} + {{nav-item route='adminWebHooks' label='admin.web_hooks.title'}} {{nav-item route='admin.backups' label='admin.backups.title'}} {{/if}} {{nav-item route='adminPlugins' label='admin.plugins.title'}} diff --git a/app/assets/javascripts/admin/templates/components/admin-web-hook-event-chooser.hbs b/app/assets/javascripts/admin/templates/components/admin-web-hook-event-chooser.hbs new file mode 100644 index 000000000..0ecb553d3 --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/admin-web-hook-event-chooser.hbs @@ -0,0 +1,3 @@ +{{input id=typeName type="checkbox" name="event-choice" checked=enabled}} + +
{{details}}
diff --git a/app/assets/javascripts/admin/templates/components/admin-web-hook-event.hbs b/app/assets/javascripts/admin/templates/components/admin-web-hook-event.hbs new file mode 100644 index 000000000..be4252c42 --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/admin-web-hook-event.hbs @@ -0,0 +1,19 @@ +{{headers}}
+ {{body}}
+ {{i18n 'admin.web_hooks.events.none'}}
+{{/if}} +{{i18n 'admin.web_hooks.detailed_instruction'}}
+ + +{{i18n 'admin.web_hooks.instruction'}}
+ {{#load-more selector=".web-hooks tr" action="loadMore"}} +{{i18n 'admin.web_hooks.delivery_status.title'}} | +{{i18n 'admin.web_hooks.payload_url'}} | +{{i18n 'admin.web_hooks.description'}} | +{{i18n 'admin.web_hooks.controls'}} | +
---|---|---|---|
{{#link-to 'adminWebHooks.showEvents' webHook.id}}{{admin-web-hook-status deliveryStatuses=deliveryStatuses model=webHook}}{{/link-to}} | +{{#link-to 'adminWebHooks.show' webHook}}{{webHook.payload_url}}{{/link-to}} | +{{webHook.description}} | ++ {{#link-to 'adminWebHooks.show' webHook tagName='button' classNames='btn btn-default no-text'}}{{fa-icon 'edit'}}{{/link-to}} + {{d-button class="destroy btn-danger" action='destroy' actionParam=webHook icon="remove"}} + | +
{{i18n 'admin.web_hooks.none'}}
+{{/if}} +