diff --git a/.travis.yml b/.travis.yml index 01ff46d8..b48ce894 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,7 @@ # Uncomment tests runner when tests are added. sudo: required -#services: +#names: #- docker before_install: diff --git a/assets/javascripts/discourse/components/wizard-custom-action.js.es6 b/assets/javascripts/discourse/components/wizard-custom-action.js.es6 index 8a0f39e8..4957dbc6 100644 --- a/assets/javascripts/discourse/components/wizard-custom-action.js.es6 +++ b/assets/javascripts/discourse/components/wizard-custom-action.js.es6 @@ -4,6 +4,7 @@ const ACTION_TYPES = [ { id: 'create_topic', name: 'Create Topic' }, { id: 'update_profile', name: 'Update Profile' }, { id: 'send_message', name: 'Send Message' }, + { id: 'send_to_api', name: 'Send to API' } { id: 'add_to_group', name: 'Add to Group' }, { id: 'route_to', name: 'Route To' } ]; @@ -28,6 +29,8 @@ export default Ember.Component.extend({ createTopic: Ember.computed.equal('action.type', 'create_topic'), updateProfile: Ember.computed.equal('action.type', 'update_profile'), sendMessage: Ember.computed.equal('action.type', 'send_message'), + sendToApi: Ember.computed.equal('action.type', 'send_to_api'), + apiEmpty: Ember.computed.empty('action.api'), addToGroup: Ember.computed.equal('action.type', 'add_to_group'), routeTo: Ember.computed.equal('action.type', 'route_to'), disableId: Ember.computed.not('action.isNew'), @@ -54,5 +57,21 @@ export default Ember.Component.extend({ toggleCustomCategoryWizardField() { const user = this.get('action.custom_category_user_field'); if (user) this.set('action.custom_category_wizard_field', false); + }, + + @computed('wizard.apis') + availableApis(apis) { + return apis.map(a => { + return { + id: a.name, + name: a.title + }; + }); + }, + + @computed('wizard.apis', 'action.api') + availableEndpoints(apis, api) { + if (!api) return []; + return apis.find(a => a.name === api).endpoints; } }); diff --git a/assets/javascripts/discourse/controllers/admin-wizards-api.js.es6 b/assets/javascripts/discourse/controllers/admin-wizards-api.js.es6 new file mode 100644 index 00000000..42451421 --- /dev/null +++ b/assets/javascripts/discourse/controllers/admin-wizards-api.js.es6 @@ -0,0 +1,216 @@ +import { ajax } from 'discourse/lib/ajax'; +import { popupAjaxError } from 'discourse/lib/ajax-error'; +import CustomWizardApi from '../models/custom-wizard-api'; +import { default as computed } from 'ember-addons/ember-computed-decorators'; + +export default Ember.Controller.extend({ + queryParams: ['refresh_list'], + loadingSubscriptions: false, + notAuthorized: Ember.computed.not('api.authorized'), + endpointMethods: ['GET', 'PUT', 'POST', 'PATCH', 'DELETE'], + showRemove: Ember.computed.not('isNew'), + showRedirectUri: Ember.computed.and('threeLeggedOauth', 'api.name'), + responseIcon: null, + + @computed('saveDisabled', 'api.authType', 'api.authUrl', 'api.tokenUrl', 'api.clientId', 'api.clientSecret', 'threeLeggedOauth') + authDisabled(saveDisabled, authType, authUrl, tokenUrl, clientId, clientSecret, threeLeggedOauth) { + if (saveDisabled || !authType || !tokenUrl || !clientId || !clientSecret) return true; + if (threeLeggedOauth) return !authUrl; + return false; + }, + + @computed('api.name', 'api.authType') + saveDisabled(name, authType) { + return !name || !authType; + }, + + authorizationTypes: ['basic', 'oauth_2', 'oauth_3'], + isBasicAuth: Ember.computed.equal('api.authType', 'basic'), + + @computed('api.authType') + isOauth(authType) { + return authType && authType.indexOf('oauth') > -1; + }, + + twoLeggedOauth: Ember.computed.equal('api.authType', 'oauth_2'), + threeLeggedOauth: Ember.computed.equal('api.authType', 'oauth_3'), + + actions: { + addParam() { + this.get('api.authParams').pushObject({}); + }, + + removeParam(param) { + this.get('api.authParams').removeObject(param); + }, + + addEndpoint() { + this.get('api.endpoints').pushObject({}); + }, + + removeEndpoint(endpoint) { + this.get('api.endpoints').removeObject(endpoint); + }, + + authorize() { + const api = this.get('api'); + const { name, authType, authUrl, authParams } = api; + + this.set('authErrorMessage', ''); + + if (authType === 'oauth_2') { + this.set('authorizing', true); + ajax(`/admin/wizards/apis/${name.underscore()}/authorize`).catch(popupAjaxError) + .then(result => { + if (result.success) { + this.set('api', CustomWizardApi.create(result.api)); + } else if (result.failed && result.message) { + this.set('authErrorMessage', result.message); + } else { + this.set('authErrorMessage', 'Authorization Failed'); + } + setTimeout(() => { + this.set('authErrorMessage', ''); + }, 6000); + }).finally(() => this.set('authorizing', false)); + } else if (authType === 'oauth_3') { + let query = '?'; + + query += `client_id=${api.clientId}`; + query += `&redirect_uri=${encodeURIComponent(api.redirectUri)}`; + query += `&response_type=code`; + + if (authParams) { + authParams.forEach(p => { + query += `&${p.key}=${encodeURIComponent(p.value)}`; + }); + } + + window.location.href = authUrl + query; + } + }, + + save() { + const api = this.get('api'); + const name = api.name; + const authType = api.authType; + let refreshList = false; + let error; + + if (!name || !authType) return; + + let data = { + auth_type: authType + }; + + if (api.title) data['title'] = api.title; + + const originalTitle = this.get('api.originalTitle'); + if (api.get('isNew') || (originalTitle && (api.title !== originalTitle))) { + refreshList = true; + } + + if (api.get('isNew')) { + data['new'] = true; + }; + + let requiredParams; + + if (authType === 'basic') { + requiredParams = ['username', 'password']; + } else if (authType === 'oauth_2') { + requiredParams = ['tokenUrl', 'clientId', 'clientSecret']; + } else if (authType === 'oauth_3') { + requiredParams = ['authUrl', 'tokenUrl', 'clientId', 'clientSecret']; + } + + for (let rp of requiredParams) { + if (!api[rp]) { + let key = rp.replace('auth', ''); + error = `${I18n.t(`admin.wizard.api.auth.${key.underscore()}`)} is required for ${authType}`; + break; + } + data[rp.underscore()] = api[rp]; + } + + const params = api.authParams; + if (params.length) { + data['auth_params'] = JSON.stringify(params); + } + + const endpoints = api.endpoints; + if (endpoints.length) { + for (let e of endpoints) { + if (!e.name) { + error = 'Every endpoint must have a name'; + break; + } + } + data['endpoints'] = JSON.stringify(endpoints); + } + + if (error) { + this.set('error', error); + setTimeout(() => { + this.set('error', ''); + }, 6000); + return; + } + + this.set('updating', true); + + ajax(`/admin/wizards/apis/${name.underscore()}`, { + type: 'PUT', + data + }).catch(popupAjaxError) + .then(result => { + if (result.success) { + if (refreshList) { + this.transitionToRoute('adminWizardsApi', result.api.name.dasherize()).then(() => { + this.send('refreshModel'); + }); + } else { + this.set('api', CustomWizardApi.create(result.api)); + this.set('responseIcon', 'check'); + } + } else { + this.set('responseIcon', 'times'); + } + }).finally(() => this.set('updating', false)); + }, + + remove() { + const name = this.get('api.name'); + if (!name) return; + + this.set('updating', true); + + ajax(`/admin/wizards/apis/${name.underscore()}`, { + type: 'DELETE' + }).catch(popupAjaxError) + .then(result => { + if (result.success) { + this.transitionToRoute('adminWizardsApis').then(() => { + this.send('refreshModel'); + }); + } + }).finally(() => this.set('updating', false)); + }, + + clearLogs() { + const name = this.get('api.name'); + if (!name) return; + + ajax(`/admin/wizards/apis/logs/${name.underscore()}`, { + type: 'DELETE' + }).catch(popupAjaxError) + .then(result => { + if (result.success) { + this.transitionToRoute('adminWizardsApis').then(() => { + this.send('refreshModel'); + }); + } + }).finally(() => this.set('updating', false)); + } + } +}); diff --git a/assets/javascripts/discourse/controllers/admin-wizards-apis.js.es6 b/assets/javascripts/discourse/controllers/admin-wizards-apis.js.es6 new file mode 100644 index 00000000..52748bc8 --- /dev/null +++ b/assets/javascripts/discourse/controllers/admin-wizards-apis.js.es6 @@ -0,0 +1,3 @@ +export default Ember.Controller.extend({ + queryParams: ['refresh'] +}); diff --git a/assets/javascripts/discourse/custom-wizard-admin-route-map.js.es6 b/assets/javascripts/discourse/custom-wizard-admin-route-map.js.es6 index 46d0e411..f5022153 100644 --- a/assets/javascripts/discourse/custom-wizard-admin-route-map.js.es6 +++ b/assets/javascripts/discourse/custom-wizard-admin-route-map.js.es6 @@ -8,6 +8,9 @@ export default { this.route('adminWizardsSubmissions', { path: '/submissions', resetNamespace: true }, function() { this.route('adminWizardSubmissions', { path: '/:wizard_id', resetNamespace: true }); }); + this.route('adminWizardsApis', { path: '/apis', resetNamespace: true }, function() { + this.route('adminWizardsApi', { path: '/:name', resetNamespace: true }); + }); }); } }; diff --git a/assets/javascripts/discourse/models/custom-wizard-api.js.es6 b/assets/javascripts/discourse/models/custom-wizard-api.js.es6 new file mode 100644 index 00000000..b335b88a --- /dev/null +++ b/assets/javascripts/discourse/models/custom-wizard-api.js.es6 @@ -0,0 +1,62 @@ +import { ajax } from 'discourse/lib/ajax'; +import { default as computed } from 'ember-addons/ember-computed-decorators'; + +const CustomWizardApi = Discourse.Model.extend({ + @computed('name') + redirectUri(name) { + let nameParam = name.toString().dasherize(); + const baseUrl = location.protocol+'//'+location.hostname+(location.port ? ':'+location.port: ''); + return baseUrl + `/admin/wizards/apis/${nameParam}/redirect`; + } +}); + +CustomWizardApi.reopenClass({ + create(params = {}) { + const api = this._super.apply(this); + const authorization = params.authorization || {}; + const endpoints = params.endpoints; + + api.setProperties({ + name: params.name, + title: params.title, + originalTitle: params.title, + authType: authorization.auth_type, + authUrl: authorization.auth_url, + tokenUrl: authorization.token_url, + clientId: authorization.client_id, + clientSecret: authorization.client_secret, + username: authorization.username, + password: authorization.password, + authParams: Ember.A(authorization.auth_params), + authorized: authorization.authorized, + accessToken: authorization.access_token, + refreshToken: authorization.refresh_token, + code: authorization.code, + tokenExpiresAt: authorization.token_expires_at, + tokenRefreshAt: authorization.token_refresh_at, + endpoints: Ember.A(endpoints), + isNew: params.isNew, + log: params.log + }); + + return api; + }, + + find(name) { + return ajax(`/admin/wizards/apis/${name}`, { + type: 'GET' + }).then(result => { + return CustomWizardApi.create(result); + }); + }, + + list() { + return ajax("/admin/wizards/apis", { + type: 'GET' + }).then(result => { + return result; + }); + } +}); + +export default CustomWizardApi; diff --git a/assets/javascripts/discourse/models/custom-wizard.js.es6 b/assets/javascripts/discourse/models/custom-wizard.js.es6 index 3141afc2..7e8ad927 100644 --- a/assets/javascripts/discourse/models/custom-wizard.js.es6 +++ b/assets/javascripts/discourse/models/custom-wizard.js.es6 @@ -17,6 +17,7 @@ const wizardProperties = [ const CustomWizard = Discourse.Model.extend({ save() { return new Ember.RSVP.Promise((resolve, reject) => { + const id = this.get('id'); if (!id || !id.underscore()) return reject({ error: 'id_required' }); @@ -129,6 +130,16 @@ const CustomWizard = Discourse.Model.extend({ error = 'id_required'; return; } + //check if api_body is valid JSON + let api_body = a.get('api_body'); + if (api_body != '') { + try { + JSON.parse(api_body); + } catch (e) { + error = 'invalid_api_body'; + return; + } + } a.set('id', id.underscore()); diff --git a/assets/javascripts/discourse/routes/admin-wizard.js.es6 b/assets/javascripts/discourse/routes/admin-wizard.js.es6 index aeba38b0..a10f625e 100644 --- a/assets/javascripts/discourse/routes/admin-wizard.js.es6 +++ b/assets/javascripts/discourse/routes/admin-wizard.js.es6 @@ -33,7 +33,8 @@ export default Discourse.Route.extend({ afterModel(model) { return Ember.RSVP.all([ this._getFieldTypes(model), - this._getThemes(model) + this._getThemes(model), + this._getApis(model) ]); }, @@ -48,6 +49,11 @@ export default Discourse.Route.extend({ }); }, + _getApis(model) { + return ajax('/admin/wizards/apis') + .then((result) => model.set('apis', result)); + }, + setupController(controller, model) { const newWizard = this.get('newWizard'); const steps = model.get('steps') || []; diff --git a/assets/javascripts/discourse/routes/admin-wizards-api.js.es6 b/assets/javascripts/discourse/routes/admin-wizards-api.js.es6 new file mode 100644 index 00000000..58f624b5 --- /dev/null +++ b/assets/javascripts/discourse/routes/admin-wizards-api.js.es6 @@ -0,0 +1,21 @@ +import CustomWizardApi from '../models/custom-wizard-api'; + +export default Discourse.Route.extend({ + queryParams: { + refresh_list: { + refreshModel: true + } + }, + + model(params) { + if (params.name === 'new') { + return CustomWizardApi.create({ isNew: true }); + } else { + return CustomWizardApi.find(params.name); + } + }, + + setupController(controller, model){ + controller.set("api", model); + } +}); diff --git a/assets/javascripts/discourse/routes/admin-wizards-apis.js.es6 b/assets/javascripts/discourse/routes/admin-wizards-apis.js.es6 new file mode 100644 index 00000000..54174c6b --- /dev/null +++ b/assets/javascripts/discourse/routes/admin-wizards-apis.js.es6 @@ -0,0 +1,31 @@ +import CustomWizardApi from '../models/custom-wizard-api'; + +export default Discourse.Route.extend({ + model() { + return CustomWizardApi.list(); + }, + + afterModel(model) { + const apiParams = this.paramsFor('admin-wizards-api'); + + if (model.length) { + if (!apiParams.name) { + this.transitionTo('adminWizardsApi', model[0].name.dasherize()); + } else { + return; + } + } else { + this.transitionTo('adminWizardsApi', 'new'); + } + }, + + setupController(controller, model){ + controller.set("model", model); + }, + + actions: { + refreshModel() { + this.refresh(); + } + } +}); diff --git a/assets/javascripts/discourse/templates/admin-wizard-submissions.hbs b/assets/javascripts/discourse/templates/admin-wizard-submissions.hbs deleted file mode 100644 index ec8a55ff..00000000 --- a/assets/javascripts/discourse/templates/admin-wizard-submissions.hbs +++ /dev/null @@ -1,16 +0,0 @@ -
- - - {{#each fields as |f|}} - - {{/each}} - - {{#each submissions as |s|}} - - {{#each-in s as |k v|}} - - {{/each-in}} - - {{/each}} -
{{f}}
{{v}}
-
diff --git a/assets/javascripts/discourse/templates/admin-wizards-api.hbs b/assets/javascripts/discourse/templates/admin-wizards-api.hbs new file mode 100644 index 00000000..160225ea --- /dev/null +++ b/assets/javascripts/discourse/templates/admin-wizards-api.hbs @@ -0,0 +1,282 @@ +
+
+ {{#if updating}} + {{loading-spinner size="small"}} + {{else}} + {{#if responseIcon}} + {{d-icon responseIcon}} + {{/if}} + {{/if}} + + {{d-button label="admin.wizard.api.save" action=(action "save") class="btn-primary" disabled=saveDisabled}} + + {{#if showRemove}} + {{d-button action=(action "remove") label="admin.wizard.api.remove"}} + {{/if}} + + {{#if error}} +
+ {{error}} +
+ {{/if}} +
+ +
+ {{#if api.isNew}} + {{i18n 'admin.wizard.api.new'}} + {{else}} + {{api.title}} + {{/if}} +
+ +
+
+ + {{input value=api.title placeholder=(i18n 'admin.wizard.api.title_placeholder')}} +
+ +
+ + {{#if api.isNew}} + {{input value=api.name placeholder=(i18n 'admin.wizard.api.name_placeholder')}} + {{else}} + {{api.name}} + {{/if}} +
+
+
+ +
+
+ {{#if isOauth}} + {{#if authorizing}} + {{loading-spinner size="small"}} + {{else}} + {{#if authErrorMessage}} + {{authErrorMessage}} + {{/if}} + {{/if}} + {{d-button label="admin.wizard.api.auth.btn" + action=(action "authorize") + disabled=authDisabled + class="btn-primary"}} + {{/if}} +
+ +
+ {{i18n 'admin.wizard.api.auth.label'}} +
+
+ +
+
+ +
+ {{i18n 'admin.wizard.api.auth.settings'}} +
+ + {{#if showRedirectUri}} +
+
+ +
+ {{api.redirectUri}} +
+
+
+ {{/if}} + +
+ +
+ {{combo-box value=api.authType content=authorizationTypes none='admin.wizard.api.auth.type_none'}} +
+
+ + {{#if isOauth}} + {{#if threeLeggedOauth}} +
+ +
+ {{input value=api.authUrl}} +
+
+ {{/if}} + +
+ +
+ {{input value=api.tokenUrl}} +
+
+ +
+ +
+ {{input value=api.clientId}} +
+
+ +
+ +
+ {{input value=api.clientSecret}} +
+
+ +
+ +
+ {{#each api.authParams as |param|}} +
+ {{input value=param.key placeholder=(i18n 'admin.wizard.api.auth.params.key')}} + {{input value=param.value placeholder=(i18n 'admin.wizard.api.auth.params.value')}} + {{d-button action=(action "removeParam") actionParam=param icon='times'}} +
+ {{/each}} + {{d-button label='admin.wizard.api.auth.params.new' icon='plus' action=(action "addParam")}} +
+
+ {{/if}} + + {{#if isBasicAuth}} +
+ +
+ {{input value=api.username}} +
+
+ +
+ +
+ {{input value=api.password}} +
+
+ {{/if}} +
+ + {{#if isOauth}} +
+
+ {{#if api.authorized}} + + {{i18n "admin.wizard.api.status.authorized"}} + {{else}} + + {{i18n "admin.wizard.api.status.not_authorized"}} + {{/if}} +
+ +
+ {{i18n 'admin.wizard.api.status.label'}} +
+ + {{#if threeLeggedOauth}} +
+ +
+ {{api.code}} +
+
+ {{/if}} + +
+ +
+ {{api.accessToken}} +
+
+ + {{#if threeLeggedOauth}} +
+ +
+ {{api.refreshToken}} +
+
+ {{/if}} + +
+ +
+ {{api.tokenExpiresAt}} +
+
+ +
+ +
+ {{api.tokenRefreshAt}} +
+
+
+ {{/if}} +
+ +
+ {{i18n 'admin.wizard.api.endpoint.label'}} +
+ +
+ {{d-button action=(action "addEndpoint") label='admin.wizard.api.endpoint.add' icon='plus'}} + + {{#if api.endpoints}} +
+ +
+ {{/if}} +
+ +
+ {{i18n 'admin.wizard.api.log.label'}} + {{d-button action=(action "clearLogs") + icon='trash-alt' + class='clear-logs'}} +
+ +
+
+ + + + + + + {{#each api.log as |logentry|}} + + + + + + + + {{/each}} +
DatetimeUserStatusURLError
{{logentry.time}} + + {{logentry.status}}{{logentry.url}}{{logentry.error}}
+
+
diff --git a/assets/javascripts/discourse/templates/admin-wizards-apis.hbs b/assets/javascripts/discourse/templates/admin-wizards-apis.hbs new file mode 100644 index 00000000..c5f67660 --- /dev/null +++ b/assets/javascripts/discourse/templates/admin-wizards-apis.hbs @@ -0,0 +1,26 @@ +
+
+ +
+ {{#link-to 'adminWizardsApi' 'new' class="btn"}} + {{d-icon "plus"}} {{i18n 'admin.wizard.api.new'}} + {{/link-to}} +
+
+ +
+ {{outlet}} +
+
diff --git a/assets/javascripts/discourse/templates/admin-wizards.hbs b/assets/javascripts/discourse/templates/admin-wizards.hbs index f855b33d..3c26e24a 100644 --- a/assets/javascripts/discourse/templates/admin-wizards.hbs +++ b/assets/javascripts/discourse/templates/admin-wizards.hbs @@ -1,6 +1,7 @@ {{#admin-nav}} {{nav-item route='adminWizardsCustom' label='admin.wizard.custom_label'}} {{nav-item route='adminWizardsSubmissions' label='admin.wizard.submissions_label'}} + {{nav-item route='adminWizardsApis' label='admin.wizard.api.nav_label'}} {{/admin-nav}}
diff --git a/assets/javascripts/discourse/templates/components/wizard-custom-action.hbs b/assets/javascripts/discourse/templates/components/wizard-custom-action.hbs index 4ef128c0..59fa22ab 100644 --- a/assets/javascripts/discourse/templates/components/wizard-custom-action.hbs +++ b/assets/javascripts/discourse/templates/components/wizard-custom-action.hbs @@ -96,7 +96,7 @@ {{d-editor value=action.post_template - placeholder='admin.wizard.action.post_builder.placeholder' + placeholder='admin.wizard.action.interpolate_fields' classNames='post-builder-editor'}}
@@ -157,7 +157,7 @@ {{d-editor value=action.post_template - placeholder='admin.wizard.action.post_builder.placeholder' + placeholder='admin.wizard.action.interpolate_fields' classNames='post-builder-editor'}} @@ -205,6 +205,44 @@ {{/if}} +{{#if sendToApi}} +
+
+

{{i18n "admin.wizard.action.send_to_api.api"}}

+
+
+ {{combo-box value=action.api + content=availableApis + none='admin.wizard.action.send_to_api.select_an_api' + isDisabled=action.custom_title_enabled}} +
+
+ +
+
+

{{i18n "admin.wizard.action.send_to_api.endpoint"}}

+
+
+ {{combo-box value=action.api_endpoint + content=availableEndpoints + none='admin.wizard.action.send_to_api.select_an_endpoint' + isDisabled=apiEmpty}} +
+
+ +
+
+

{{i18n "admin.wizard.action.send_to_api.body"}}

+
+
+ + + {{textarea value=action.api_body + placeholder=(i18n 'admin.wizard.action.interpolate_fields')}} +
+
+{{/if}} + {{#if addToGroup}}
diff --git a/assets/stylesheets/wizard_custom_admin.scss b/assets/stylesheets/wizard_custom_admin.scss index 832188bd..0cfb4bb7 100644 --- a/assets/stylesheets/wizard_custom_admin.scss +++ b/assets/stylesheets/wizard_custom_admin.scss @@ -122,6 +122,23 @@ margin-top: 5px; padding: 5px; } + + .api-body { + width: 100%; + + .setting-label { + max-width: 70px; + } + + .setting-value { + width: calc(100% - 180px); + } + + textarea { + width: 100%; + min-height: 150px; + } + } } .wizard-links { @@ -312,6 +329,142 @@ } } -.wizard-step-contents{ - height: unset !important; +.wizard-step-contents { + height: unset !important; +} + +.admin-wizards-api { + margin-bottom: 40px; + + .content-list { + margin-right: 20px; + } + + .new-api { + margin-top: 20px; + } + + .metadata .title input { + width: 400px; + } + + .buttons { + text-align: right; + vertical-align: middle; + + > .d-icon, > .spinner { + margin-right: 7px; + } + + .error { + margin-top: 10px; + color: $danger; + } + } +} + +.wizard-api-header { + &.page { + margin-bottom: 20px; + } + + .buttons { + float: right; + } + + .wizard-header { + overflow: hidden; + } +} + +.wizard-api-authentication { + display: flex; + background-color: $primary-very-low; + padding: 20px; + margin-bottom: 20px; + + .settings { + width: 50%; + max-width: 50%; + } + + .redirect-uri .controls { + word-break: break-all; + } + + .auth-type .select-kit { + min-width: 210px; + width: 210px; + margin-bottom: 10px; + } + + .status { + border-left: 1px solid $primary; + margin-left: 20px; + padding-left: 20px; + width: 50%; + max-width: 50%; + + .wizard-header { + overflow: hidden; + } + + .authorization { + float: right; + } + + .control-group { + margin-bottom: 15px; + } + } +} + +.wizard-api-endpoints { + background-color: $primary-very-low; + padding: 20px; + margin-bottom: 20px; + + .endpoint-list { + margin-top: 20px; + + ul { + margin: 0; + list-style: none; + } + } + + .endpoint { + display: flex; + margin-top: 20px; + + .combo-box { + width: 200px; + margin-right: 10px; + margin-top: -2px; + width: 150px; + } + + input { + margin: 0; + margin-right: 10px; + } + + .endpoint-url { + width: 300px; + } + + .remove-endpoint { + margin-left: auto; + } + } +} + +.wizard-api-log { + background-color: #f8f8f8; + padding: 20px; + margin-bottom: 20px; +} + +.wizard-step-contents{ + height: unset !important; } diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 194e1428..7634dcc5 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -59,6 +59,7 @@ en: name_required: "Wizards must have a name." steps_required: "Wizards must have at least one step." id_required: "All wizards, steps, fields and actions need an id." + invalid_api_body: "Request body JSON needs to be a valid JSON." type_required: "All fields need a type." after_time_need_time: "After time is enabled but no time is set." after_time_invalid: "After time is invalid." @@ -114,6 +115,8 @@ en: add_fields: "{{type}} Fields" available_fields: "* If 'Save wizard submissions' is disabled, only the fields of the current step are available to the current step's actions." topic_attr: "Topic Attribute" + interpolate_fields: "Insert wizard fields using the field_id in w{}. Insert user fields using field key in u{}." + skip_redirect: label: "Skip Redirect" description: "Don't redirect the user to this {{type}} after the wizard completes" @@ -146,6 +149,64 @@ en: label: "Custom Category" wizard_field: "Wizard Field" user_field: "User Field" + send_to_api: + label: "Send to API" + api: "API" + endpoint: "Endpoint" + select_an_api: "Select an API" + select_an_endpoint: "Select an endpoint" + body: "Request body JSON" + + api: + label: "API" + nav_label: 'APIs' + new: 'New Api' + name: "Name (can't be changed)" + name_placeholder: 'Underscored' + title: 'Title' + title_placeholder: 'Display name' + remove: 'Delete' + save: "Save" + + auth: + label: "Authorization" + btn: 'Authorize' + settings: "Settings" + status: "Status" + redirect_uri: "Redirect url" + type: 'Type' + type_none: 'Select a type' + url: "Authorization url" + token_url: "Token url" + client_id: 'Client id' + client_secret: 'Client secret' + username: 'username' + password: 'password' + params: + label: 'Params' + new: 'New param' + key: 'key' + value: 'value' + + status: + label: "Status" + authorized: 'Authorized' + not_authorized: "Not authorized" + code: "Code" + access_token: "Access token" + refresh_token: "Refresh token" + expires_at: "Expires at" + refresh_at: "Refresh at" + + endpoint: + label: "Endpoints" + add: "Add endpoint" + name: "Endpoint name" + method: "Select a method" + url: "Enter a url" + + log: + label: "Logs" wizard_js: location: diff --git a/controllers/api.rb b/controllers/api.rb new file mode 100644 index 00000000..a4ed8e6d --- /dev/null +++ b/controllers/api.rb @@ -0,0 +1,138 @@ +class CustomWizard::ApiController < ::ApplicationController + before_action :ensure_logged_in + before_action :ensure_admin + skip_before_action :check_xhr, only: [:redirect] + + def index + end + + def list + serializer = ActiveModel::ArraySerializer.new( + CustomWizard::Api.list, + each_serializer: CustomWizard::BasicApiSerializer + ) + render json: MultiJson.dump(serializer) + end + + def find + render_serialized(CustomWizard::Api.get(api_params[:name]), CustomWizard::ApiSerializer, root: false) + end + + def save + current = CustomWizard::Api.get(api_params[:name]) + + if api_params[:new] && current + raise Discourse::InvalidParameters, "An API with that name already exists: '#{current.title || current.name}'" + end + + PluginStoreRow.transaction do + CustomWizard::Api.set(api_params[:name], title: api_params[:title]) + + if auth_data.present? + auth_data['auth_params'] = auth_data['auth_params'] || [] + CustomWizard::Api::Authorization.set(api_params[:name], auth_data) + end + + if api_params[:endpoints].is_a? String + begin + endpoints = JSON.parse(api_params[:endpoints]) + endpoints.each do |endpoint| + CustomWizard::Api::Endpoint.set(api_params[:name], endpoint) + end + rescue => e + puts e + end + end + end + + render json: success_json.merge( + api: CustomWizard::ApiSerializer.new( + CustomWizard::Api.get(api_params[:name]), + root: false + ) + ) + end + + def remove + PluginStoreRow.transaction do + CustomWizard::Api.remove(api_params[:name]) + CustomWizard::Api::Authorization.remove(api_params[:name]) + CustomWizard::Api::Endpoint.remove(api_params[:name]) + CustomWizard::Api::LogEntry.clear(api_params[:name]) + end + + render json: success_json + end + + def authorize + result = CustomWizard::Api::Authorization.get_token(api_params[:name]) + + if result.instance_variable_defined?(:@error) + render json: failed_json.merge(message: result['error_description'] || result['error']) + else + render json: success_json.merge( + api: CustomWizard::ApiSerializer.new( + CustomWizard::Api.get(api_params[:name]), + root: false + ) + ) + end + end + + def clearlogs + CustomWizard::Api::LogEntry.clear(api_params[:name]) + render json: success_json + end + + def redirect + params.require(:name) + params.require(:code) + + CustomWizard::Api::Authorization.set(params[:name], code: params[:code]) + CustomWizard::Api::Authorization.get_token(params[:name]) + + return redirect_to path('/admin/wizards/apis/' + params[:name]) + end + + private + + def api_params + params.require(:name) + + data = params.permit( + :name, + :title, + :auth_type, + :auth_url, + :token_url, + :client_id, + :client_secret, + :username, + :password, + :auth_params, + :endpoints, + :new + ).to_h + + data[:name] = data[:name].underscore + + @api_params ||= data + end + + def auth_data + auth_data = api_params.slice( + :auth_type, + :auth_url, + :token_url, + :client_id, + :client_secret, + :username, + :password, + :auth_params + ) + + auth_data[:auth_params] = JSON.parse(auth_data[:auth_params]) if auth_data[:auth_params].present? + + @auth_data ||= auth_data + end +end diff --git a/jobs/refresh_api_access_token.rb b/jobs/refresh_api_access_token.rb new file mode 100644 index 00000000..54f6da55 --- /dev/null +++ b/jobs/refresh_api_access_token.rb @@ -0,0 +1,7 @@ +module Jobs + class RefreshApiAccessToken < Jobs::Base + def execute(args) + CustomWizard::Api::Authorization.get_token(args[:name], refresh: true) + end + end +end diff --git a/lib/api/api.rb b/lib/api/api.rb new file mode 100644 index 00000000..a5d4c155 --- /dev/null +++ b/lib/api/api.rb @@ -0,0 +1,34 @@ +class CustomWizard::Api + include ActiveModel::SerializerSupport + + attr_accessor :name, + :title + + def initialize(name, data={}) + @name = name + data.each do |k, v| + self.send "#{k}=", v if self.respond_to?(k) + end + end + + def self.set(name, data) + PluginStore.set("custom_wizard_api_#{name}", "metadata", data) + end + + def self.get(name) + if data = PluginStore.get("custom_wizard_api_#{name}", "metadata") + self.new(name, data) + end + end + + def self.remove(name) + PluginStore.remove("custom_wizard_api_#{name}", "metadata") + end + + def self.list + PluginStoreRow.where("plugin_name LIKE 'custom_wizard_api_%' AND key = 'metadata'") + .map do |record| + self.new(record['plugin_name'].sub("custom_wizard_api_", ""), ::JSON.parse(record['value'])) + end + end +end diff --git a/lib/api/authorization.rb b/lib/api/authorization.rb new file mode 100644 index 00000000..745ecc9b --- /dev/null +++ b/lib/api/authorization.rb @@ -0,0 +1,151 @@ +require 'excon' + +class CustomWizard::Api::Authorization + include ActiveModel::SerializerSupport + + attr_accessor :api_name, + :authorized, + :auth_type, + :auth_url, + :token_url, + :client_id, + :client_secret, + :auth_params, + :access_token, + :refresh_token, + :token_expires_at, + :token_refresh_at, + :code, + :username, + :password + + def initialize(api_name, data={}) + @api_name = api_name + + data.each do |k, v| + self.send "#{k}=", v if self.respond_to?(k) + end + end + + def authorized + @authorized ||= @access_token && @token_expires_at.to_datetime > Time.now + end + + def self.set(api_name, new_data = {}) + + api_name = api_name.underscore + + data = self.get(api_name, data_only: true) || {} + + new_data.each do |k, v| + data[k.to_sym] = v + end + + PluginStore.set("custom_wizard_api_#{api_name}", 'authorization', data) + + self.get(api_name) + end + + def self.get(api_name, opts = {}) + api_name = api_name.underscore + + if data = PluginStore.get("custom_wizard_api_#{api_name}", 'authorization') + if opts[:data_only] + data + else + self.new(api_name, data) + end + else + nil + end + end + + def self.remove(api_name) + PluginStore.remove("custom_wizard_api_#{api_name}", "authorization") + end + + def self.get_header_authorization_string(name) + auth = CustomWizard::Api::Authorization.get(name) + raise Discourse::InvalidParameters.new(:name) unless auth.present? + + if auth.auth_type === "basic" + raise Discourse::InvalidParameters.new(:username) unless auth.username.present? + raise Discourse::InvalidParameters.new(:password) unless auth.password.present? + "Basic #{Base64.strict_encode64((auth.username + ":" + auth.password).chomp)}" + else + raise Discourse::InvalidParameters.new(auth.access_token) unless auth.access_token.present? + "Bearer #{auth.access_token}" + end + end + + def self.get_token(name, opts = {}) + authorization = CustomWizard::Api::Authorization.get(name) + type = authorization.auth_type + + body = {} + + if opts[:refresh] && type === 'oauth_3' + body['grant_type'] = 'refresh_token' + elsif type === 'oauth_2' + body['grant_type'] = 'client_credentials' + elsif type === 'oauth_3' + body['grant_type'] = 'authorization_code' + end + + unless opts[:refresh] + body['client_id'] = authorization.client_id + body['client_secret'] = authorization.client_secret + end + + if type === 'oauth_3' + body['code'] = authorization.code + body['redirect_uri'] = Discourse.base_url + "/admin/wizards/apis/#{name}/redirect" + end + + connection = Excon.new( + authorization.token_url, + :headers => { + "Content-Type" => "application/x-www-form-urlencoded" + }, + :method => 'GET', + :query => URI.encode_www_form(body) + ) + begin + result = connection.request() + log_params = {time: Time.now, user_id: 0, status: 'SUCCESS', url: authorization.token_url, error: ""} + CustomWizard::Api::LogEntry.set(name, log_params) + rescue SystemCallError => e + log_params = {time: Time.now, user_id: 0, status: 'FAILURE', url: authorization.token_url, error: "Token refresh request failed: #{e.inspect}"} + CustomWizard::Api::LogEntry.set(name, log_params) + end + + self.handle_token_result(name, result) + end + + def self.handle_token_result(name, result) + result_data = JSON.parse(result.body) + + if result_data['error'] + return result_data + end + + data = {} + + data['access_token'] = result_data['access_token'] + data['refresh_token'] = result_data['refresh_token'] if result_data['refresh_token'] + data['token_type'] = result_data['token_type'] if result_data['token_type'] + + if result_data['expires_in'] + data['token_expires_at'] = Time.now + result_data['expires_in'].seconds + data['token_refresh_at'] = data['token_expires_at'].to_time - 10.minutes + + opts = { + name: name + } + + Jobs.enqueue_at(data['token_refresh_at'], :refresh_api_access_token, opts) + end + + CustomWizard::Api::Authorization.set(name, data) + end +end diff --git a/lib/api/endpoint.rb b/lib/api/endpoint.rb new file mode 100644 index 00000000..3036a49f --- /dev/null +++ b/lib/api/endpoint.rb @@ -0,0 +1,101 @@ +class CustomWizard::Api::Endpoint + include ActiveModel::SerializerSupport + + attr_accessor :id, + :name, + :api_name, + :method, + :url + + def initialize(api_name, data={}) + @api_name = api_name + + data.each do |k, v| + self.send "#{k}=", v if self.respond_to?(k) + end + end + + def self.set(api_name, new_data) + if new_data['id'] + data = self.get(api_name, new_data['id'], data_only: true) + endpoint_id = new_data['id'] + else + data = {} + endpoint_id = SecureRandom.hex(3) + end + + new_data.each do |k, v| + data[k.to_sym] = v + end + + PluginStore.set("custom_wizard_api_#{api_name}", "endpoint_#{endpoint_id}", data) + + self.get(api_name, endpoint_id) + end + + def self.get(api_name, endpoint_id, opts={}) + return nil if !endpoint_id + + if data = PluginStore.get("custom_wizard_api_#{api_name}", "endpoint_#{endpoint_id}") + if opts[:data_only] + data + else + data[:id] = endpoint_id + self.new(api_name, data) + end + else + nil + end + end + + def self.remove(api_name) + PluginStoreRow.where("plugin_name = 'custom_wizard_api_#{api_name}' AND key LIKE 'endpoint_%'").destroy_all + end + + def self.list(api_name) + PluginStoreRow.where("plugin_name LIKE 'custom_wizard_api_#{api_name}' AND key LIKE 'endpoint_%'") + .map do |record| + api_name = record['plugin_name'].sub("custom_wizard_api_", "") + data = ::JSON.parse(record['value']) + data[:id] = record['key'].split('_').last + self.new(api_name, data) + end + end + + def self.request(user, api_name, endpoint_id, body) + endpoint = self.get(api_name, endpoint_id) + auth = CustomWizard::Api::Authorization.get_header_authorization_string(api_name) + + connection = Excon.new( + URI.parse(URI.encode(endpoint.url)).to_s, + :headers => { + "Authorization" => auth, + "Accept" => "application/json, */*", + "Content-Type" => "application/json" + } + ) + + params = { + method: endpoint.method + } + + if body + body = JSON.generate(body) + body.delete! '\\' + params[:body] = body + end + + begin + response = connection.request(params) + log_params = {time: Time.now, user_id: user.id, status: 'SUCCESS', url: endpoint.url, error: ""} + + CustomWizard::Api::LogEntry.set(api_name, log_params) + return JSON.parse(response.body) + rescue + # TODO: improve error detail + log_params = {time: Time.now, user_id: user.id, status: 'FAILURE', url: endpoint.url, error: "API request failed"} + CustomWizard::Api::LogEntry.set(api_name, log_params) + return JSON.parse "[{\"error\":\"API request failed\"}]" + end + end +end diff --git a/lib/api/logentry.rb b/lib/api/logentry.rb new file mode 100644 index 00000000..1408615d --- /dev/null +++ b/lib/api/logentry.rb @@ -0,0 +1,88 @@ +class CustomWizard::Api::LogEntry + include ActiveModel::SerializerSupport + + attr_accessor :log_id, + :time, + :user_id, + :status, + :url, + :error, + :username, + :userpath, + :name, + :avatar_template + + def initialize(api_name, data={}) + @api_name = api_name + + data.each do |k, v| + self.send "#{k}=", v if self.respond_to?(k) + end + end + + def self.set(api_name, new_data) + if new_data['log_id'] + data = self.get(api_name, new_data['log_id'], data_only: true) + log_id = new_data['log_id'] + else + data = {} + log_id = SecureRandom.hex(3) + end + + new_data.each do |k, v| + data[k.to_sym] = v + end + + PluginStore.set("custom_wizard_api_#{api_name}", "log_#{log_id}", data) + + self.get(api_name, log_id) + end + + def self.get(api_name, log_id, opts={}) + return nil if !log_id + + if data = PluginStore.get("custom_wizard_api_#{api_name}", "log_#{log_id}") + if opts[:data_only] + data + else + data[:log_id] = log_id + self.new(api_name, data) + end + else + nil + end + end + + def self.remove(api_name) + PluginStoreRow.where("plugin_name = 'custom_wizard_api_#{api_name}' AND key LIKE 'log_%'").destroy_all + end + + def self.list(api_name) + PluginStoreRow.where("plugin_name LIKE 'custom_wizard_api_#{api_name}' AND key LIKE 'log_%'") + .map do |record| + api_name = record['plugin_name'].sub("custom_wizard_api_", "") + data = ::JSON.parse(record['value']) + data[:log_id] = record['key'].split('_').last + this_user = User.find_by(id: data['user_id']) + unless this_user.nil? + data[:user_id] = this_user.id || nil + data[:username] = this_user.username || "" + data[:userpath] = "/u/#{this_user.username_lower}/activity" + data[:name] = this_user.name || "" + data[:avatar_template] = "/user_avatar/default/#{this_user.username_lower}/97/#{this_user.uploaded_avatar_id}.png" + else + data[:user_id] = nil + data[:username] = "" + data[:userpath] = "" + data[:name] = "" + data[:avatar_template] = "" + end + self.new(api_name, data) + end + end + + def self.clear(api_name) + PluginStoreRow.where("plugin_name = 'custom_wizard_api_#{api_name}' AND key LIKE 'log_%'").destroy_all + end + +end diff --git a/lib/builder.rb b/lib/builder.rb index 45b191b4..8533c4c3 100644 --- a/lib/builder.rb +++ b/lib/builder.rb @@ -444,6 +444,28 @@ class CustomWizard::Builder end end + def send_to_api(user, action, data) + api_body = nil + + if action['api_body'] != "" + begin + api_body_parsed = JSON.parse(action['api_body']) + rescue JSON::ParserError + raise Discourse::InvalidParameters, "Invalid API body definition: #{action['api_body']} for #{action['title']}" + end + api_body = CustomWizard::Builder.fill_placeholders(JSON.generate(api_body_parsed), user, data) + end + + result = CustomWizard::Api::Endpoint.request(user, action['api'], action['api_endpoint'], api_body) + + if error = result['error'] || (result[0] && result[0]['error']) + error = error['message'] || error + updater.errors.add(:send_to_api, error) + else + ## add validation callback + end + end + def add_to_group(user, action, data) if group_id = data[action['group_id']] if group = Group.find(group_id) diff --git a/plugin.rb b/plugin.rb index 231bb7e1..1709b7ea 100644 --- a/plugin.rb +++ b/plugin.rb @@ -54,6 +54,7 @@ after_initialize do require_dependency 'admin_constraint' Discourse::Application.routes.append do mount ::CustomWizard::Engine, at: 'w' + post 'wizard/authorization/callback' => "custom_wizard/authorization#callback" scope module: 'custom_wizard', constraints: AdminConstraint.new do get 'admin/wizards' => 'admin#index' @@ -66,6 +67,14 @@ after_initialize do delete 'admin/wizards/custom/remove' => 'admin#remove' get 'admin/wizards/submissions' => 'admin#index' get 'admin/wizards/submissions/:wizard_id' => 'admin#submissions' + get 'admin/wizards/apis' => 'api#list' + get 'admin/wizards/apis/new' => 'api#index' + get 'admin/wizards/apis/:name' => 'api#find' + put 'admin/wizards/apis/:name' => 'api#save' + delete 'admin/wizards/apis/:name' => 'api#remove' + delete 'admin/wizards/apis/logs/:name' => 'api#clearlogs' + get 'admin/wizards/apis/:name/redirect' => 'api#redirect' + get 'admin/wizards/apis/:name/authorize' => 'api#authorize' end end @@ -81,6 +90,19 @@ after_initialize do load File.expand_path('../controllers/steps.rb', __FILE__) load File.expand_path('../controllers/admin.rb', __FILE__) + load File.expand_path('../jobs/refresh_api_access_token.rb', __FILE__) + load File.expand_path('../lib/api/api.rb', __FILE__) + load File.expand_path('../lib/api/authorization.rb', __FILE__) + load File.expand_path('../lib/api/endpoint.rb', __FILE__) + load File.expand_path('../lib/api/logentry.rb', __FILE__) + load File.expand_path('../controllers/api.rb', __FILE__) + load File.expand_path('../serializers/api/api_serializer.rb', __FILE__) + load File.expand_path('../serializers/api/authorization_serializer.rb', __FILE__) + load File.expand_path('../serializers/api/basic_api_serializer.rb', __FILE__) + load File.expand_path('../serializers/api/endpoint_serializer.rb', __FILE__) + load File.expand_path('../serializers/api/basic_endpoint_serializer.rb', __FILE__) + load File.expand_path('../serializers/api/log_serializer.rb', __FILE__) + ::UsersController.class_eval do def wizard_path if custom_wizard_redirect = current_user.custom_fields['redirect_to_wizard'] diff --git a/serializers/api/api_serializer.rb b/serializers/api/api_serializer.rb new file mode 100644 index 00000000..9d7fba70 --- /dev/null +++ b/serializers/api/api_serializer.rb @@ -0,0 +1,34 @@ +class CustomWizard::ApiSerializer < ApplicationSerializer + attributes :name, + :title, + :authorization, + :endpoints, + :log + + def authorization + if authorization = CustomWizard::Api::Authorization.get(object.name) + CustomWizard::Api::AuthorizationSerializer.new( + authorization, + root: false + ) + end + end + + def endpoints + if endpoints = CustomWizard::Api::Endpoint.list(object.name) + ActiveModel::ArraySerializer.new( + endpoints, + each_serializer: CustomWizard::Api::EndpointSerializer + ) + end + end + + def log + if log = CustomWizard::Api::LogEntry.list(object.name) + ActiveModel::ArraySerializer.new( + log, + each_serializer: CustomWizard::Api::LogSerializer + ) + end + end +end diff --git a/serializers/api/authorization_serializer.rb b/serializers/api/authorization_serializer.rb new file mode 100644 index 00000000..2ca347b5 --- /dev/null +++ b/serializers/api/authorization_serializer.rb @@ -0,0 +1,16 @@ +class CustomWizard::Api::AuthorizationSerializer < ApplicationSerializer + attributes :auth_type, + :auth_url, + :token_url, + :client_id, + :client_secret, + :authorized, + :auth_params, + :access_token, + :refresh_token, + :token_expires_at, + :token_refresh_at, + :code, + :username, + :password +end diff --git a/serializers/api/basic_api_serializer.rb b/serializers/api/basic_api_serializer.rb new file mode 100644 index 00000000..d0214d65 --- /dev/null +++ b/serializers/api/basic_api_serializer.rb @@ -0,0 +1,14 @@ +class CustomWizard::BasicApiSerializer < ApplicationSerializer + attributes :name, + :title, + :endpoints + + def endpoints + if endpoints = CustomWizard::Api::Endpoint.list(object.name) + ActiveModel::ArraySerializer.new( + endpoints, + each_serializer: CustomWizard::Api::BasicEndpointSerializer + ) + end + end +end diff --git a/serializers/api/basic_endpoint_serializer.rb b/serializers/api/basic_endpoint_serializer.rb new file mode 100644 index 00000000..e2cc2262 --- /dev/null +++ b/serializers/api/basic_endpoint_serializer.rb @@ -0,0 +1,4 @@ +class CustomWizard::Api::BasicEndpointSerializer < ApplicationSerializer + attributes :id, + :name +end diff --git a/serializers/api/endpoint_serializer.rb b/serializers/api/endpoint_serializer.rb new file mode 100644 index 00000000..18c1406c --- /dev/null +++ b/serializers/api/endpoint_serializer.rb @@ -0,0 +1,10 @@ +class CustomWizard::Api::EndpointSerializer < ApplicationSerializer + attributes :id, + :name, + :method, + :url + + def method + object.send('method') + end +end diff --git a/serializers/api/log_serializer.rb b/serializers/api/log_serializer.rb new file mode 100644 index 00000000..13ee1555 --- /dev/null +++ b/serializers/api/log_serializer.rb @@ -0,0 +1,12 @@ +class CustomWizard::Api::LogSerializer < ApplicationSerializer + attributes :log_id, + :time, + :status, + :url, + :error, + :user_id, + :username, + :userpath, + :name, + :avatar_template +end