0
0
Fork 1
Spiegel von https://github.com/paviliondev/discourse-custom-wizard.git synchronisiert 2024-11-09 20:02:54 +01:00

Merge pull request #12 from angusmcleod/api_authentication

Api authentication
Dieser Commit ist enthalten in:
Angus McLeod 2019-07-12 11:01:12 +10:00 committet von GitHub
Commit 91a9d30a8a
Es konnte kein GPG-Schlüssel zu dieser Signatur gefunden werden
GPG-Schlüssel-ID: 4AEE18F83AFDEB23
31 geänderte Dateien mit 1592 neuen und 22 gelöschten Zeilen

Datei anzeigen

@ -1,7 +1,7 @@
# Uncomment tests runner when tests are added.
sudo: required
#services:
#names:
#- docker
before_install:

Datei anzeigen

@ -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;
}
});

Datei anzeigen

@ -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));
}
}
});

Datei anzeigen

@ -0,0 +1,3 @@
export default Ember.Controller.extend({
queryParams: ['refresh']
});

Datei anzeigen

@ -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 });
});
});
}
};

Datei anzeigen

@ -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;

Datei anzeigen

@ -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());

Datei anzeigen

@ -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') || [];

Datei anzeigen

@ -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);
}
});

Datei anzeigen

@ -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();
}
}
});

Datei anzeigen

@ -1,16 +0,0 @@
<div class="wizard-submissions">
<table>
<tr>
{{#each fields as |f|}}
<th>{{f}}</th>
{{/each}}
</tr>
{{#each submissions as |s|}}
<tr>
{{#each-in s as |k v|}}
<td>{{v}}</td>
{{/each-in}}
</tr>
{{/each}}
</table>
</div>

Datei anzeigen

@ -0,0 +1,282 @@
<div class="wizard-api-header page">
<div class='buttons'>
{{#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}}
<div class="error">
{{error}}
</div>
{{/if}}
</div>
<div class="wizard-header">
{{#if api.isNew}}
{{i18n 'admin.wizard.api.new'}}
{{else}}
{{api.title}}
{{/if}}
</div>
<div class="metadata">
<div class="title">
<label>{{i18n 'admin.wizard.api.title'}}</label>
{{input value=api.title placeholder=(i18n 'admin.wizard.api.title_placeholder')}}
</div>
<div class="name">
<label>{{i18n 'admin.wizard.api.name'}}</label>
{{#if api.isNew}}
{{input value=api.name placeholder=(i18n 'admin.wizard.api.name_placeholder')}}
{{else}}
{{api.name}}
{{/if}}
</div>
</div>
</div>
<div class="wizard-api-header">
<div class="buttons">
{{#if isOauth}}
{{#if authorizing}}
{{loading-spinner size="small"}}
{{else}}
{{#if authErrorMessage}}
<span>{{authErrorMessage}}</span>
{{/if}}
{{/if}}
{{d-button label="admin.wizard.api.auth.btn"
action=(action "authorize")
disabled=authDisabled
class="btn-primary"}}
{{/if}}
</div>
<div class="wizard-header">
{{i18n 'admin.wizard.api.auth.label'}}
</div>
</div>
<div class="wizard-api-authentication">
<div class="settings">
<div class="wizard-header medium">
{{i18n 'admin.wizard.api.auth.settings'}}
</div>
{{#if showRedirectUri}}
<div class="control-group redirect-uri">
<div class="control-label">
<label>{{i18n 'admin.wizard.api.auth.redirect_uri'}}</label>
<div class="controls">
{{api.redirectUri}}
</div>
</div>
</div>
{{/if}}
<div class="control-group auth-type">
<label>{{i18n 'admin.wizard.api.auth.type'}}</label>
<div class="controls">
{{combo-box value=api.authType content=authorizationTypes none='admin.wizard.api.auth.type_none'}}
</div>
</div>
{{#if isOauth}}
{{#if threeLeggedOauth}}
<div class="control-group">
<label>{{i18n 'admin.wizard.api.auth.url'}}</label>
<div class="controls">
{{input value=api.authUrl}}
</div>
</div>
{{/if}}
<div class="control-group">
<label>{{i18n 'admin.wizard.api.auth.token_url'}}</label>
<div class="controls">
{{input value=api.tokenUrl}}
</div>
</div>
<div class="control-group">
<label>{{i18n 'admin.wizard.api.auth.client_id'}}</label>
<div class="controls">
{{input value=api.clientId}}
</div>
</div>
<div class="control-group">
<label>{{i18n 'admin.wizard.api.auth.client_secret'}}</label>
<div class="controls">
{{input value=api.clientSecret}}
</div>
</div>
<div class="control-group">
<label>{{i18n 'admin.wizard.api.auth.params.label'}}</label>
<div class="controls">
{{#each api.authParams as |param|}}
<div class="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'}}
</div>
{{/each}}
{{d-button label='admin.wizard.api.auth.params.new' icon='plus' action=(action "addParam")}}
</div>
</div>
{{/if}}
{{#if isBasicAuth}}
<div class="control-group">
<label>{{i18n 'admin.wizard.api.auth.username'}}</label>
<div class="controls">
{{input value=api.username}}
</div>
</div>
<div class="control-group">
<label>{{i18n 'admin.wizard.api.auth.password'}}</label>
<div class="controls">
{{input value=api.password}}
</div>
</div>
{{/if}}
</div>
{{#if isOauth}}
<div class="status">
<div class="authorization">
{{#if api.authorized}}
<span class="authorization-indicator authorized"></span>
<span>{{i18n "admin.wizard.api.status.authorized"}}</span>
{{else}}
<span class="authorization-indicator not-authorized"></span>
<span>{{i18n "admin.wizard.api.status.not_authorized"}}</span>
{{/if}}
</div>
<div class="wizard-header medium">
{{i18n 'admin.wizard.api.status.label'}}
</div>
{{#if threeLeggedOauth}}
<div class="control-group">
<label>{{i18n 'admin.wizard.api.status.code'}}</label>
<div class="controls">
{{api.code}}
</div>
</div>
{{/if}}
<div class="control-group">
<label>{{i18n 'admin.wizard.api.status.access_token'}}</label>
<div class="controls">
{{api.accessToken}}
</div>
</div>
{{#if threeLeggedOauth}}
<div class="control-group">
<label>{{i18n 'admin.wizard.api.status.refresh_token'}}</label>
<div class="controls">
{{api.refreshToken}}
</div>
</div>
{{/if}}
<div class="control-group">
<label>{{i18n 'admin.wizard.api.status.expires_at'}}</label>
<div class="controls">
{{api.tokenExpiresAt}}
</div>
</div>
<div class="control-group">
<label>{{i18n 'admin.wizard.api.status.refresh_at'}}</label>
<div class="controls">
{{api.tokenRefreshAt}}
</div>
</div>
</div>
{{/if}}
</div>
<div class="wizard-header">
{{i18n 'admin.wizard.api.endpoint.label'}}
</div>
<div class="wizard-api-endpoints">
{{d-button action=(action "addEndpoint") label='admin.wizard.api.endpoint.add' icon='plus'}}
{{#if api.endpoints}}
<div class="endpoint-list">
<ul>
{{#each api.endpoints as |endpoint|}}
<li>
<div class="endpoint">
<div class="endpoint-">
{{input value=endpoint.name
placeholder=(i18n 'admin.wizard.api.endpoint.name')}}
{{combo-box content=endpointMethods
value=endpoint.method
none="admin.wizard.api.endpoint.method"}}
{{input value=endpoint.url
placeholder=(i18n 'admin.wizard.api.endpoint.url')
class='endpoint-url'}}
{{d-button action=(action "removeEndpoint")
actionParam=endpoint
icon='times'
class='remove-endpoint'}}
</div>
</div>
</li>
{{/each}}
</ul>
</div>
{{/if}}
</div>
<div class="wizard-header">
{{i18n 'admin.wizard.api.log.label'}}
{{d-button action=(action "clearLogs")
icon='trash-alt'
class='clear-logs'}}
</div>
<div class="wizard-api-log">
<div class="log-list">
<table class="wizard-api-log-table">
<th>Datetime</th>
<th>User</th>
<th>Status</th>
<th>URL</th>
<th>Error</th>
{{#each api.log as |logentry|}}
<tr>
<td>{{logentry.time}}</td>
<td class="user-image">
<div class="user-image-inner">
<a href="{{unbound logentry.userpath}}" data-user-card="{{unbound logentry.username}}">{{avatar logentry imageSize="large"}}</a>
</div>
</td>
<td>{{logentry.status}}</td>
<td>{{logentry.url}}</td>
<td>{{logentry.error}}</td>
</tr>
{{/each}}
</table>
</div>
</div>

Datei anzeigen

@ -0,0 +1,26 @@
<div class='row'>
<div class='content-list wizard-list'>
<ul>
{{#each model as |api|}}
<li>
{{#link-to "adminWizardsApi" (dasherize api.name)}}
{{#if api.title}}
{{api.title}}
{{else}}
{{api.name}}
{{/if}}
{{/link-to}}
</li>
{{/each}}
</ul>
<div class="new-api">
{{#link-to 'adminWizardsApi' 'new' class="btn"}}
{{d-icon "plus"}} {{i18n 'admin.wizard.api.new'}}
{{/link-to}}
</div>
</div>
<div class="content">
{{outlet}}
</div>
</div>

Datei anzeigen

@ -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}}
<div class="admin-container">

Datei anzeigen

@ -96,7 +96,7 @@
<label>{{i18n 'admin.wizard.action.post_builder.user_fields'}}{{builderUserFields}}</label>
<label>{{i18n 'admin.wizard.action.post_builder.wizard_fields'}}{{builderWizardFields}}</label>
{{d-editor value=action.post_template
placeholder='admin.wizard.action.post_builder.placeholder'
placeholder='admin.wizard.action.interpolate_fields'
classNames='post-builder-editor'}}
</div>
</div>
@ -157,7 +157,7 @@
<label>{{i18n 'admin.wizard.action.post_builder.user_fields'}}{{builderUserFields}}</label>
<label>{{i18n 'admin.wizard.action.post_builder.wizard_fields'}}{{builderWizardFields}}</label>
{{d-editor value=action.post_template
placeholder='admin.wizard.action.post_builder.placeholder'
placeholder='admin.wizard.action.interpolate_fields'
classNames='post-builder-editor'}}
</div>
</div>
@ -205,6 +205,44 @@
</div>
{{/if}}
{{#if sendToApi}}
<div class="setting">
<div class="setting-label">
<h3>{{i18n "admin.wizard.action.send_to_api.api"}}</h3>
</div>
<div class="setting-value">
{{combo-box value=action.api
content=availableApis
none='admin.wizard.action.send_to_api.select_an_api'
isDisabled=action.custom_title_enabled}}
</div>
</div>
<div class="setting">
<div class="setting-label">
<h3>{{i18n "admin.wizard.action.send_to_api.endpoint"}}</h3>
</div>
<div class="setting-value">
{{combo-box value=action.api_endpoint
content=availableEndpoints
none='admin.wizard.action.send_to_api.select_an_endpoint'
isDisabled=apiEmpty}}
</div>
</div>
<div class="setting api-body">
<div class="setting-label">
<h3>{{i18n "admin.wizard.action.send_to_api.body"}}</h3>
</div>
<div class="setting-value">
<label>{{i18n 'admin.wizard.action.post_builder.user_fields'}}{{builderUserFields}}</label>
<label>{{i18n 'admin.wizard.action.post_builder.wizard_fields'}}{{builderWizardFields}}</label>
{{textarea value=action.api_body
placeholder=(i18n 'admin.wizard.action.interpolate_fields')}}
</div>
</div>
{{/if}}
{{#if addToGroup}}
<div class="setting">
<div class="setting-label">

Datei anzeigen

@ -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;
}

Datei anzeigen

@ -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:

138
controllers/api.rb Normale Datei
Datei anzeigen

@ -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

Datei anzeigen

@ -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

34
lib/api/api.rb Normale Datei
Datei anzeigen

@ -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

151
lib/api/authorization.rb Normale Datei
Datei anzeigen

@ -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

101
lib/api/endpoint.rb Normale Datei
Datei anzeigen

@ -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

88
lib/api/logentry.rb Normale Datei
Datei anzeigen

@ -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

Datei anzeigen

@ -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)

Datei anzeigen

@ -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']

Datei anzeigen

@ -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

Datei anzeigen

@ -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

Datei anzeigen

@ -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

Datei anzeigen

@ -0,0 +1,4 @@
class CustomWizard::Api::BasicEndpointSerializer < ApplicationSerializer
attributes :id,
:name
end

Datei anzeigen

@ -0,0 +1,10 @@
class CustomWizard::Api::EndpointSerializer < ApplicationSerializer
attributes :id,
:name,
:method,
:url
def method
object.send('method')
end
end

Datei anzeigen

@ -0,0 +1,12 @@
class CustomWizard::Api::LogSerializer < ApplicationSerializer
attributes :log_id,
:time,
:status,
:url,
:error,
:user_id,
:username,
:userpath,
:name,
:avatar_template
end