diff --git a/app.js b/app.js
index 7f0f9023..a15dd80a 100644
--- a/app.js
+++ b/app.js
@@ -50,6 +50,7 @@ const reportTemplatesRest = require('./routes/rest/report-templates');
const reportsRest = require('./routes/rest/reports');
const campaignsRest = require('./routes/rest/campaigns');
const listsRest = require('./routes/rest/lists');
+const sharesRest = require('./routes/rest/shares');
const namespacesLegacyIntegration = require('./routes/namespaces-legacy-integration');
const usersLegacyIntegration = require('./routes/users-legacy-integration');
@@ -271,6 +272,7 @@ app.use('/rest', usersRest);
app.use('/rest', accountRest);
app.use('/rest', campaignsRest);
app.use('/rest', listsRest);
+app.use('/rest', sharesRest);
if (config.reports && config.reports.enabled === true) {
app.use('/rest', reportTemplatesRest);
diff --git a/client/src/account/API.js b/client/src/account/API.js
index 15ff182f..5a243a2c 100644
--- a/client/src/account/API.js
+++ b/client/src/account/API.js
@@ -2,7 +2,7 @@
import React, { Component } from 'react';
import { translate, Trans } from 'react-i18next';
-import { withPageHelpers, Title } from '../lib/page'
+import { requiresAuthenticatedUser, withPageHelpers, Title } from '../lib/page'
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import URL from 'url-parse';
import axios from '../lib/axios';
@@ -11,6 +11,7 @@ import { Button } from '../lib/bootstrap-components';
@translate()
@withPageHelpers
@withErrorHandling
+@requiresAuthenticatedUser
export default class API extends Component {
constructor(props) {
super(props);
diff --git a/client/src/account/Account.js b/client/src/account/Account.js
index 3948ba93..159a8cea 100644
--- a/client/src/account/Account.js
+++ b/client/src/account/Account.js
@@ -2,7 +2,7 @@
import React, { Component } from 'react';
import { translate, Trans } from 'react-i18next';
-import { withPageHelpers, Title } from '../lib/page'
+import { requiresAuthenticatedUser, withPageHelpers, Title } from '../lib/page'
import {
withForm, Form, Fieldset, FormSendMethod, InputField, ButtonRow, Button
} from '../lib/form';
@@ -15,6 +15,7 @@ import mailtrainConfig from 'mailtrainConfig';
@withForm
@withPageHelpers
@withErrorHandling
+@requiresAuthenticatedUser
export default class Account extends Component {
constructor(props) {
super(props);
@@ -120,6 +121,8 @@ export default class Account extends Component {
this.updateFormValue('currentPassword', '');
this.clearFormStatusMessage();
+ this.enableForm();
+
} else {
this.enableForm();
this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.'));
diff --git a/client/src/account/Forgot.js b/client/src/account/Forgot.js
index 7efb91b1..f0b8aae0 100644
--- a/client/src/account/Forgot.js
+++ b/client/src/account/Forgot.js
@@ -4,7 +4,7 @@ import React, { Component } from 'react';
import { translate } from 'react-i18next';
import { withPageHelpers, Title } from '../lib/page'
import {
- withForm, Form, FormSendMethod, InputField, CheckBox, ButtonRow, Button, AlignedRow
+ withForm, Form, FormSendMethod, InputField, ButtonRow, Button
} from '../lib/form';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
diff --git a/client/src/account/Login.js b/client/src/account/Login.js
index d0b0d7d8..b3b8c103 100644
--- a/client/src/account/Login.js
+++ b/client/src/account/Login.js
@@ -59,6 +59,8 @@ export default class Login extends Component {
this.setFormStatusMessage('info', t('Verifying credentials ...'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(FormSendMethod.POST, '/rest/login');
+ /* FIXME, once we turn Mailtrain to single-page application, we should receive authenticated config (from client-helpers.js:getAuthenticatedConfig)
+ as part of login response. Then we should integrate it in the mailtrainConfig global variable. */
if (submitSuccessful) {
const query = new URL(this.props.location.search, true).query;
diff --git a/client/src/account/root.js b/client/src/account/root.js
index e7e928be..8dca08f8 100644
--- a/client/src/account/root.js
+++ b/client/src/account/root.js
@@ -5,12 +5,12 @@ import ReactDOM from 'react-dom';
import { I18nextProvider } from 'react-i18next';
import i18n from '../lib/i18n';
-import { Section } from '../lib/page'
-import Account from './Account'
-import Login from './Login'
-import Reset from './Forgot'
-import ResetLink from './Reset'
-import API from './API'
+import { Section } from '../lib/page';
+import Account from './Account';
+import Login from './Login';
+import Reset from './Forgot';
+import ResetLink from './Reset';
+import API from './API';
import mailtrainConfig from 'mailtrainConfig';
diff --git a/client/src/lib/error-handling.js b/client/src/lib/error-handling.js
index 252f898a..c9aa71e6 100644
--- a/client/src/lib/error-handling.js
+++ b/client/src/lib/error-handling.js
@@ -23,7 +23,7 @@ function handleError(that, error) {
function withErrorHandling(target) {
const inst = target.prototype;
- if (inst._withErrorHandlingApplied) return;
+ if (inst._withErrorHandlingApplied) return target;
inst._withErrorHandlingApplied = true;
const contextTypes = target.contextTypes || {};
diff --git a/client/src/lib/form.js b/client/src/lib/form.js
index 83fffdd0..ae653ce7 100644
--- a/client/src/lib/form.js
+++ b/client/src/lib/form.js
@@ -356,7 +356,7 @@ class TreeTableSelect extends Component {
}
}
-@translate()
+@translate(null, { withRef: true })
class TableSelect extends Component {
constructor(props) {
super(props);
@@ -369,6 +369,7 @@ class TableSelect extends Component {
static propTypes = {
dataUrl: PropTypes.string,
+ data: PropTypes.array,
columns: PropTypes.array,
selectionKeyIndex: PropTypes.number,
selectionLabelIndex: PropTypes.number,
@@ -426,6 +427,10 @@ class TableSelect extends Component {
});
}
+ refresh() {
+ this.table.refresh();
+ }
+
render() {
const props = this.props;
const owner = this.context.formStateOwner;
@@ -443,7 +448,7 @@ class TableSelect extends Component {
-
+ this.table = node} data={props.data} dataUrl={props.dataUrl} columns={props.columns} selectMode={props.selectMode} selectionAsArray={this.props.selectionAsArray} withHeader={props.withHeader} selection={owner.getFormValue(id)} onSelectionDataAsync={::this.onSelectionDataAsync} onSelectionChangedAsync={::this.onSelectionChangedAsync}/>
);
@@ -451,7 +456,7 @@ class TableSelect extends Component {
return wrapInput(id, htmlId, owner, props.label, props.help,
-
+ this.table = node} data={props.data} dataUrl={props.dataUrl} columns={props.columns} selectMode={props.selectMode} selectionAsArray={this.props.selectionAsArray} withHeader={props.withHeader} selection={owner.getFormValue(id)} onSelectionChangedAsync={::this.onSelectionChangedAsync}/>
);
@@ -459,6 +464,15 @@ class TableSelect extends Component {
}
}
+/*
+ Refreshes the table. This method is provided to allow programmatic refresh from a handler outside the table.
+ The reference to the table can be obtained by ref.
+ */
+TableSelect.prototype.refresh = function() {
+ this.getWrappedInstance().refresh()
+};
+
+
class ACEEditor extends Component {
static propTypes = {
id: PropTypes.string.isRequired,
diff --git a/client/src/lib/page.css b/client/src/lib/page.css
index 029b6b67..1dbf2b8f 100644
--- a/client/src/lib/page.css
+++ b/client/src/lib/page.css
@@ -38,4 +38,9 @@
.mt-tableselect-dropdown input[readonly] {
background-color: white;
+}
+
+h3.legend {
+ font-size: 21px;
+ margin-bottom: 20px;
}
\ No newline at end of file
diff --git a/client/src/lib/page.js b/client/src/lib/page.js
index 45d86b83..3f10b1ed 100644
--- a/client/src/lib/page.js
+++ b/client/src/lib/page.js
@@ -9,6 +9,7 @@ import './page.css';
import { withErrorHandling } from './error-handling';
import interoperableErrors from '../../../shared/interoperable-errors';
import { DismissibleAlert, Button } from './bootstrap-components';
+import mailtrainConfig from 'mailtrainConfig';
class PageContent extends Component {
@@ -200,10 +201,16 @@ class SectionContent extends Component {
this.setFlashMessage(severity, text);
}
- errorHandler(error) {
- if (error instanceof interoperableErrors.NotLoggedInError) {
+ ensureAuthenticated() {
+ if (!mailtrainConfig.isAuthenticated) {
/* FIXME, once we turn Mailtrain to single-page application, this should become navigateTo */
window.location = '/account/login?next=' + encodeURIComponent(this.props.root);
+ }
+ }
+
+ errorHandler(error) {
+ if (error instanceof interoperableErrors.NotLoggedInError) {
+ this.ensureAuthenticated();
} else if (error.response && error.response.data && error.response.data.message) {
console.error(error);
this.navigateToWithFlashMessage(this.props.root, 'danger', error.response.data.message);
@@ -312,7 +319,7 @@ class DropdownLink extends Component {
}
function withPageHelpers(target) {
- withErrorHandling(target);
+ target = withErrorHandling(target);
const inst = target.prototype;
@@ -346,16 +353,32 @@ function withPageHelpers(target) {
return this.context.sectionContent.navigateToWithFlashMessage(path, severity, text);
}
- inst.axios
-
return target;
}
+function requiresAuthenticatedUser(target) {
+ const comp1 = withPageHelpers(target);
+
+ function comp2(props, context) {
+ comp1.call(this, props, context);
+ context.sectionContent.ensureAuthenticated();
+ }
+
+ comp2.prototype = comp1.prototype;
+
+ for (const attr in comp1) {
+ comp2[attr] = comp1[attr];
+ }
+
+ return comp2;
+}
+
export {
Section,
Title,
Toolbar,
NavButton,
DropdownLink,
- withPageHelpers
+ withPageHelpers,
+ requiresAuthenticatedUser
};
\ No newline at end of file
diff --git a/client/src/lib/table.js b/client/src/lib/table.js
index 8a1b63a2..529ca09c 100644
--- a/client/src/lib/table.js
+++ b/client/src/lib/table.js
@@ -6,7 +6,6 @@ import { translate } from 'react-i18next';
import PropTypes from 'prop-types';
import jQuery from 'jquery';
-import '../../public/jquery/jquery-ui-1.12.1.min.js';
import 'datatables.net';
import 'datatables.net-bs';
@@ -82,13 +81,20 @@ class Table extends Component {
selMap.set(elem, undefined);
}
- if (this.table) {
- const self = this;
- this.table.rows().every(function() {
- const data = this.data();
- const key = data[self.props.selectionKeyIndex];
+ if (props.data) {
+ for (const rowData of props.data) {
+ const key = rowData[props.selectionKeyIndex];
if (selMap.has(key)) {
- selMap.set(key, data);
+ selMap.set(key, rowData);
+ }
+ }
+
+ } else if (this.table) {
+ this.table.rows().every(function() {
+ const rowData = this.data();
+ const key = rowData[props.selectionKeyIndex];
+ if (selMap.has(key)) {
+ selMap.set(key, rowData);
}
});
}
@@ -118,26 +124,28 @@ class Table extends Component {
}
@withAsyncErrorHandler
- async fetchSelectionData() {
+ async fetchAndNotifySelectionData() {
if (this.props.onSelectionDataAsync) {
- const keysToFetch = [];
- for (const pair of this.selectionMap.entries()) {
- if (!pair[1]) {
- keysToFetch.push(pair[0]);
+ if (!this.props.data) {
+ const keysToFetch = [];
+ for (const pair of this.selectionMap.entries()) {
+ if (!pair[1]) {
+ keysToFetch.push(pair[0]);
+ }
}
- }
- if (keysToFetch.length > 0) {
- const response = await axios.post(this.props.dataUrl, {
- operation: 'getBy',
- column: this.props.selectionKeyIndex,
- values: keysToFetch
- });
+ if (keysToFetch.length > 0) {
+ const response = await axios.post(this.props.dataUrl, {
+ operation: 'getBy',
+ column: this.props.selectionKeyIndex,
+ values: keysToFetch
+ });
- for (const row of response.data) {
- const key = row[this.props.selectionKeyIndex];
- if (this.selectionMap.has(key)) {
- this.selectionMap.set(key, row);
+ for (const row of response.data) {
+ const key = row[this.props.selectionKeyIndex];
+ if (this.selectionMap.has(key)) {
+ this.selectionMap.set(key, row);
+ }
}
}
}
@@ -293,7 +301,7 @@ class Table extends Component {
clearTimeout(this.refreshTimeoutId);
});
- this.fetchSelectionData();
+ this.fetchAndNotifySelectionData();
}
componentDidUpdate() {
@@ -314,7 +322,7 @@ class Table extends Component {
}
this.updateSelectInfo();
- this.fetchSelectionData();
+ this.fetchAndNotifySelectionData();
}
async notifySelection(eventCallback, newSelectionMap) {
diff --git a/client/src/namespaces/CUD.js b/client/src/namespaces/CUD.js
index 570dce3f..bd7277a4 100644
--- a/client/src/namespaces/CUD.js
+++ b/client/src/namespaces/CUD.js
@@ -1,8 +1,9 @@
'use strict';
import React, { Component } from 'react';
+import PropTypes from 'prop-types';
import { translate } from 'react-i18next';
-import { withPageHelpers, Title } from '../lib/page'
+import { requiresAuthenticatedUser, withPageHelpers, Title } from '../lib/page';
import { withForm, Form, FormSendMethod, InputField, TextArea, ButtonRow, Button, TreeTableSelect } from '../lib/form';
import axios from '../lib/axios';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
@@ -13,6 +14,7 @@ import { ModalDialog } from '../lib/bootstrap-components';
@withForm
@withPageHelpers
@withErrorHandling
+@requiresAuthenticatedUser
export default class CUD extends Component {
constructor(props) {
super(props);
@@ -28,6 +30,10 @@ export default class CUD extends Component {
}
+ static propTypes = {
+ edit: PropTypes.bool
+ }
+
isEditGlobal() {
return this.state.entityId === 1;
}
diff --git a/client/src/namespaces/List.js b/client/src/namespaces/List.js
index af107c27..4e78d305 100644
--- a/client/src/namespaces/List.js
+++ b/client/src/namespaces/List.js
@@ -2,11 +2,17 @@
import React, { Component } from 'react';
import { translate } from 'react-i18next';
-import { Title, Toolbar, NavButton } from '../lib/page';
+import { requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, NavButton } from '../lib/page';
import { TreeTable } from '../lib/tree';
@translate()
+@withPageHelpers
+@requiresAuthenticatedUser
export default class List extends Component {
+ constructor(props) {
+ super(props);
+ }
+
render() {
const t = this.props.t;
diff --git a/client/src/namespaces/root.js b/client/src/namespaces/root.js
index a41fcb90..a49498ee 100644
--- a/client/src/namespaces/root.js
+++ b/client/src/namespaces/root.js
@@ -5,9 +5,9 @@ import ReactDOM from 'react-dom';
import { I18nextProvider } from 'react-i18next';
import i18n from '../lib/i18n';
-import { Section } from '../lib/page'
-import CUD from './CUD'
-import List from './List'
+import { Section } from '../lib/page';
+import CUD from './CUD';
+import List from './List';
const getStructure = t => ({
'': {
diff --git a/client/src/reports/CUD.js b/client/src/reports/CUD.js
index 203c1450..cc77327b 100644
--- a/client/src/reports/CUD.js
+++ b/client/src/reports/CUD.js
@@ -1,8 +1,9 @@
'use strict';
import React, { Component } from 'react';
+import PropTypes from 'prop-types';
import { translate, Trans } from 'react-i18next';
-import { withPageHelpers, Title } from '../lib/page'
+import { requiresAuthenticatedUser, withPageHelpers, Title } from '../lib/page';
import {
withForm, Form, FormSendMethod, InputField, TextArea, TableSelect, TableSelectMode, ButtonRow, Button,
Fieldset
@@ -16,6 +17,7 @@ import moment from 'moment';
@withForm
@withPageHelpers
@withErrorHandling
+@requiresAuthenticatedUser
export default class CUD extends Component {
constructor(props) {
super(props);
@@ -33,6 +35,10 @@ export default class CUD extends Component {
});
}
+ static propTypes = {
+ edit: PropTypes.bool
+ }
+
isDelete() {
return this.props.match.params.action === 'delete';
}
diff --git a/client/src/reports/List.js b/client/src/reports/List.js
index 1dc1de8f..f758edb6 100644
--- a/client/src/reports/List.js
+++ b/client/src/reports/List.js
@@ -2,7 +2,7 @@
import React, { Component } from 'react';
import { translate } from 'react-i18next';
-import { Title, Toolbar, NavButton } from '../lib/page';
+import { withPageHelpers, Title, Toolbar, NavButton } from '../lib/page';
import { Table } from '../lib/table';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import moment from 'moment';
@@ -11,6 +11,7 @@ import { ReportState } from '../../../shared/reports';
@translate()
@withErrorHandling
+@withPageHelpers
export default class List extends Component {
@withAsyncErrorHandler
diff --git a/client/src/reports/Output.js b/client/src/reports/Output.js
index b3df049b..88e158ef 100644
--- a/client/src/reports/Output.js
+++ b/client/src/reports/Output.js
@@ -2,13 +2,14 @@
import React, { Component } from 'react';
import { translate } from 'react-i18next';
-import { withPageHelpers, Title } from '../lib/page'
+import { requiresAuthenticatedUser, withPageHelpers, Title } from '../lib/page'
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import axios from '../lib/axios';
@translate()
@withPageHelpers
@withErrorHandling
+@requiresAuthenticatedUser
export default class Output extends Component {
constructor(props) {
super(props);
diff --git a/client/src/reports/View.js b/client/src/reports/View.js
index 902ec073..ac36dad7 100644
--- a/client/src/reports/View.js
+++ b/client/src/reports/View.js
@@ -2,7 +2,7 @@
import React, { Component } from 'react';
import { translate } from 'react-i18next';
-import { withPageHelpers, Title } from '../lib/page'
+import { requiresAuthenticatedUser, withPageHelpers, Title } from '../lib/page'
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import axios from '../lib/axios';
import { ReportState } from '../../../shared/reports';
@@ -10,6 +10,7 @@ import { ReportState } from '../../../shared/reports';
@translate()
@withPageHelpers
@withErrorHandling
+@requiresAuthenticatedUser
export default class View extends Component {
constructor(props) {
super(props);
diff --git a/client/src/reports/root.js b/client/src/reports/root.js
index 49f9145a..f21013dd 100644
--- a/client/src/reports/root.js
+++ b/client/src/reports/root.js
@@ -5,13 +5,15 @@ import ReactDOM from 'react-dom';
import { I18nextProvider } from 'react-i18next';
import i18n from '../lib/i18n';
-import { Section } from '../lib/page'
-import ReportsCUD from './CUD'
-import ReportsList from './List'
-import ReportsView from './View'
-import ReportsOutput from './Output'
-import ReportTemplatesCUD from './templates/CUD'
-import ReportTemplatesList from './templates/List'
+import { Section } from '../lib/page';
+import ReportsCUD from './CUD';
+import ReportsList from './List';
+import ReportsView from './View';
+import ReportsOutput from './Output';
+import ReportTemplatesCUD from './templates/CUD';
+import ReportTemplatesList from './templates/List';
+import Share from '../shares/Share';
+
const getStructure = t => {
const subPaths = {};
@@ -59,11 +61,16 @@ const getStructure = t => {
title: t('Create Report Template'),
params: [':wizard?'],
render: props => ()
+ },
+ share: {
+ title: t('Share Report Template'),
+ params: [':id'],
+ render: props => ( t('Share Report Template "{{name}}"', {name: entity.name})} getUrl={id => `/rest/report-templates/${id}`} entityTypeId="reportTemplate" {...props} />)
}
}
}
}
- },
+ }
}
}
}
diff --git a/client/src/reports/templates/CUD.js b/client/src/reports/templates/CUD.js
index 4934fc9a..7f197286 100644
--- a/client/src/reports/templates/CUD.js
+++ b/client/src/reports/templates/CUD.js
@@ -1,6 +1,7 @@
'use strict';
import React, { Component } from 'react';
+import PropTypes from 'prop-types';
import { translate, Trans } from 'react-i18next';
import { withPageHelpers, Title } from '../../lib/page'
import { withForm, Form, FormSendMethod, InputField, TextArea, Dropdown, ACEEditor, ButtonRow, Button } from '../../lib/form';
@@ -25,6 +26,10 @@ export default class CUD extends Component {
this.initForm();
}
+ static propTypes = {
+ edit: PropTypes.bool
+ }
+
isDelete() {
return this.props.match.params.action === 'delete';
}
diff --git a/client/src/reports/templates/List.js b/client/src/reports/templates/List.js
index a1106316..edf9e091 100644
--- a/client/src/reports/templates/List.js
+++ b/client/src/reports/templates/List.js
@@ -3,12 +3,18 @@
import React, { Component } from 'react';
import { translate } from 'react-i18next';
import { DropdownMenu } from '../../lib/bootstrap-components';
-import { Title, Toolbar, DropdownLink } from '../../lib/page';
+import { requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, DropdownLink } from '../../lib/page';
import { Table } from '../../lib/table';
import moment from 'moment';
@translate()
+@withPageHelpers
+@requiresAuthenticatedUser
export default class List extends Component {
+ constructor(props) {
+ super(props);
+ }
+
render() {
const t = this.props.t;
@@ -16,6 +22,10 @@ export default class List extends Component {
{
label: 'Edit',
link: '/reports/templates/edit/' + data[0]
+ },
+ {
+ label: 'Share',
+ link: '/reports/templates/share/' + data[0]
}
];
diff --git a/client/src/shares/Share.js b/client/src/shares/Share.js
new file mode 100644
index 00000000..d1b491a8
--- /dev/null
+++ b/client/src/shares/Share.js
@@ -0,0 +1,179 @@
+'use strict';
+
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { translate } from 'react-i18next';
+import { requiresAuthenticatedUser, withPageHelpers, Title } from '../lib/page';
+import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
+import {
+ withForm, Form, FormSendMethod, TableSelect, ButtonRow, Button
+} from '../lib/form';
+import { Table } from '../lib/table';
+import axios from '../lib/axios';
+import mailtrainConfig from 'mailtrainConfig';
+
+@translate()
+@withForm
+@withPageHelpers
+@withErrorHandling
+@requiresAuthenticatedUser
+export default class Share extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ entityId: parseInt(props.match.params.id)
+ };
+
+ this.initForm();
+ }
+
+ static propTypes = {
+ title: PropTypes.func,
+ getUrl: PropTypes.func,
+ entityTypeId: PropTypes.string
+ }
+
+ @withAsyncErrorHandler
+ async loadEntity() {
+ const response = await axios.get(this.props.getUrl(this.state.entityId));
+ this.setState({
+ entity: response.data
+ });
+ }
+
+ @withAsyncErrorHandler
+ async deleteShare(userId) {
+ const data = {
+ entityTypeId: this.props.entityTypeId,
+ entityId: this.state.entityId,
+ userId
+ };
+
+ await axios.put('/rest/shares', data);
+ this.sharesTable.refresh();
+ this.usersTableSelect.refresh();
+ }
+
+ clearShareFields() {
+ this.populateFormValues({
+ entityTypeId: this.props.entityTypeId,
+ entityId: this.state.entityId,
+ userId: null,
+ role: null
+ });
+ }
+
+ componentDidMount() {
+ this.loadEntity();
+ this.clearShareFields();
+ }
+
+ localValidateFormValues(state) {
+ const t = this.props.t;
+
+ if (!state.getIn(['userId', 'value'])) {
+ state.setIn(['userId', 'error'], t('User must not be empty'));
+ } else {
+ state.setIn(['userId', 'error'], null);
+ }
+
+ if (!state.getIn(['role', 'value'])) {
+ state.setIn(['role', 'error'], t('Role must be selected'));
+ } else {
+ state.setIn(['role', 'error'], null);
+ }
+ }
+
+ async submitHandler() {
+ const t = this.props.t;
+
+ this.disableForm();
+ this.setFormStatusMessage('info', t('Saving ...'));
+
+ const submitSuccessful = await this.validateAndSendFormValuesToURL(FormSendMethod.PUT, '/rest/shares');
+
+ if (submitSuccessful) {
+ this.hideFormValidation();
+ this.clearShareFields();
+ this.enableForm();
+
+ this.clearFormStatusMessage();
+ this.sharesTable.refresh();
+ this.usersTableSelect.refresh();
+
+ } else {
+ this.enableForm();
+ this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and try again.'));
+ }
+ }
+
+ render() {
+ const t = this.props.t;
+ const roles = mailtrainConfig.roles[this.props.entityTypeId];
+
+ const actions = data => [
+ {
+ label: 'Delete',
+ action: () => this.deleteShare(data[4])
+ }
+ ];
+
+ const sharesColumns = [
+ { data: 1, title: t('Username') },
+ { data: 2, title: t('Name') },
+ { data: 3, title: t('Role'), render: data => roles[data] ? roles[data].name : data }
+ ];
+
+
+ let usersLabelIndex = 1;
+ const usersColumns = [
+ { data: 0, title: "#" },
+ { data: 1, title: "Username" },
+ ];
+
+ if (mailtrainConfig.isAuthMethodLocal) {
+ usersColumns.push({ data: 2, title: "Full Name" });
+ usersLabelIndex = 2;
+ }
+
+
+ const rolesColumns = [
+ { data: 1, title: "Name" },
+ { data: 2, title: "Description" },
+ ];
+
+
+ const rolesData = [];
+ for (const key in roles) {
+ const role = roles[key];
+ rolesData.push([ key, role.name, role.description ]);
+ }
+
+
+ if (this.state.entity) {
+ return (
+
+
{this.props.title(this.state.entity)}
+
+
{t('Add User')}
+
+
+
+
{t('Existing Users')}
+
+
this.sharesTable = node} withHeader dataUrl={`/rest/shares-table/${this.props.entityTypeId}/${this.state.entityId}`} columns={sharesColumns} actions={actions}/>
+
+ );
+ } else {
+ return ({t('Loading ...')}
)
+ }
+ }
+}
diff --git a/client/src/users/CUD.js b/client/src/users/CUD.js
index 792ae107..429811f3 100644
--- a/client/src/users/CUD.js
+++ b/client/src/users/CUD.js
@@ -1,8 +1,9 @@
'use strict';
import React, { Component } from 'react';
+import PropTypes from 'prop-types';
import { translate } from 'react-i18next';
-import { withPageHelpers, Title } from '../lib/page'
+import { requiresAuthenticatedUser, withPageHelpers, Title } from '../lib/page';
import { withForm, Form, FormSendMethod, InputField, ButtonRow, Button } from '../lib/form';
import axios from '../lib/axios';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
@@ -16,6 +17,7 @@ import mailtrainConfig from 'mailtrainConfig';
@withForm
@withPageHelpers
@withErrorHandling
+@requiresAuthenticatedUser
export default class CUD extends Component {
constructor(props) {
super(props);
@@ -37,6 +39,10 @@ export default class CUD extends Component {
});
}
+ static propTypes = {
+ edit: PropTypes.bool
+ }
+
isDelete() {
return this.props.match.params.action === 'delete';
}
diff --git a/client/src/users/List.js b/client/src/users/List.js
index 3da7104d..2ff3aa41 100644
--- a/client/src/users/List.js
+++ b/client/src/users/List.js
@@ -2,12 +2,18 @@
import React, { Component } from 'react';
import { translate } from 'react-i18next';
-import { Title, Toolbar, NavButton } from '../lib/page';
+import { requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, NavButton } from '../lib/page';
import { Table } from '../lib/table';
import mailtrainConfig from 'mailtrainConfig';
@translate()
+@withPageHelpers
+@requiresAuthenticatedUser
export default class List extends Component {
+ constructor(props) {
+ super(props);
+ }
+
render() {
const t = this.props.t;
diff --git a/client/src/users/root.js b/client/src/users/root.js
index b89e6edf..edb7a011 100644
--- a/client/src/users/root.js
+++ b/client/src/users/root.js
@@ -5,9 +5,9 @@ import ReactDOM from 'react-dom';
import { I18nextProvider } from 'react-i18next';
import i18n from '../lib/i18n';
-import { Section } from '../lib/page'
-import CUD from './CUD'
-import List from './List'
+import { Section } from '../lib/page';
+import CUD from './CUD';
+import List from './List';
import mailtrainConfig from 'mailtrainConfig';
const getStructure = t => {
diff --git a/config/default.toml b/config/default.toml
index ba6f15b8..9ccaaf17 100644
--- a/config/default.toml
+++ b/config/default.toml
@@ -188,16 +188,21 @@ logger=false
browser="phantomjs"
-[roles.list.master]
+[roles.reportTemplate.master]
name="Master"
description="All permissions"
-permissions=["view"]
+permissions=["view", "edit", "delete"]
-[roles.namespace.master]
-name="Master"
-description="All permissions"
-permissions=["view", "edit", "create", "delete", "create list"]
-
-[roles.namespace.master.childperms]
-list=["view"]
-namespace=["view", "edit", "create", "delete", "create list"]
+#[roles.list.master]
+#name="Master"
+#description="All permissions"
+#permissions=["view"]
+#
+#[roles.namespace.master]
+#name="Master"
+#description="All permissions"
+#permissions=["view", "edit", "create", "delete", "create list"]
+#
+#[roles.namespace.master.childperms]
+#list=["view"]
+#namespace=["view", "edit", "create", "delete", "create list"]
diff --git a/lib/client-helpers.js b/lib/client-helpers.js
index da5006dd..4dfee96d 100644
--- a/lib/client-helpers.js
+++ b/lib/client-helpers.js
@@ -2,29 +2,58 @@
const passport = require('./passport');
const config = require('config');
+const permissions = require('./permissions');
-function _getConfig(context) {
+function getAnonymousConfig(context) {
return {
authMethod: passport.authMethod,
isAuthMethodLocal: passport.isAuthMethodLocal,
externalPasswordResetLink: config.ldap.passwordresetlink,
language: config.language || 'en',
- userId: context.user ? context.user.id : undefined
+ isAuthenticated: !!context.user
+ }
+}
+
+function getAuthenticatedConfig(context) {
+ const roles = {};
+ for (const entityTypeId in config.roles) {
+ const rolesPerEntityType = {};
+ for (const roleId in config.roles[entityTypeId]) {
+ const roleSpec = config.roles[entityTypeId][roleId];
+
+ rolesPerEntityType[roleId] = {
+ name: roleSpec.name,
+ description: roleSpec.description
+ }
+ }
+ roles[entityTypeId] = rolesPerEntityType;
+ }
+
+
+ return {
+ userId: context.user.id,
+ roles
}
}
function registerRootRoute(router, entryPoint, title) {
router.get('/*', passport.csrfProtection, (req, res) => {
+ const mailtrainConfig = getAnonymousConfig(req.context);
+ if (req.user) {
+ Object.assign(mailtrainConfig, getAuthenticatedConfig(req.context));
+ }
+
res.render('react-root', {
title,
reactEntryPoint: entryPoint,
reactCsrfToken: req.csrfToken(),
- mailtrainConfig: JSON.stringify(_getConfig(req.context))
+ mailtrainConfig: JSON.stringify(mailtrainConfig)
});
});
}
module.exports = {
- registerRootRoute
+ registerRootRoute,
+ getAuthenticatedConfig
};
diff --git a/lib/knex.js b/lib/knex.js
index 8bd32505..3f561a4d 100644
--- a/lib/knex.js
+++ b/lib/knex.js
@@ -8,6 +8,7 @@ const knex = require('knex')({
migrations: {
directory: __dirname + '/../setup/knex/migrations'
}
+ // , debug: true
});
knex.migrate.latest();
diff --git a/lib/permissions.js b/lib/permissions.js
index 86f51000..fcbacade 100644
--- a/lib/permissions.js
+++ b/lib/permissions.js
@@ -2,13 +2,35 @@
const config = require('config');
-class ListPermission {
+
+// FIXME - redo or delete
+
+/*
+ class ReportTemplatePermission {
+ constructor(name) {
+ this.name = name;
+ this.entityType = 'report-template';
+ }
+ }
+
+ const ReportTemplatePermissions = {
+ View: new ReportTemplatePermission('view'),
+ Edit: new ReportTemplatePermission('edit'),
+ Delete: new ReportTemplatePermission('delete')
+ };
+
+
+ class ListPermission {
constructor(name) {
this.name = name;
this.entityType = 'list';
}
}
+const ListPermissions = {
+ View: new ListPermissions('view')
+};
+
class NamespacePermission {
constructor(name) {
this.name = name;
@@ -16,10 +38,6 @@ class NamespacePermission {
}
}
-const ListPermissions = {
- View: new ListPermissions('view')
-};
-
const NamespacePermissions = {
View: new NamespacePermission('view'),
Edit: new NamespacePermission('edit'),
@@ -27,7 +45,9 @@ const NamespacePermissions = {
Delete: new NamespacePermission('delete'),
CreateList: new NamespacePermission('create list')
};
+*/
+/*
async function can(context, operation, entityId) {
if (!context.user) {
return false;
@@ -48,3 +68,8 @@ async function buildPermissions() {
can(ctx, ListPermissions.View, 3)
can(ctx, NamespacePermissions.CreateList, 2)
+can(ctx, ReportTemplatePermissions.ViewReport, 5)
+*/
+
+module.exports = {
+}
\ No newline at end of file
diff --git a/lib/tools-async.js b/lib/tools-async.js
index df93c7d6..027aaa55 100644
--- a/lib/tools-async.js
+++ b/lib/tools-async.js
@@ -2,7 +2,7 @@
const _ = require('./translate')._;
const util = require('util');
-const isemail = require('isemail')
+const isemail = require('isemail');
module.exports = {
validateEmail
diff --git a/models/shares.js b/models/shares.js
new file mode 100644
index 00000000..a2004424
--- /dev/null
+++ b/models/shares.js
@@ -0,0 +1,94 @@
+'use strict';
+
+const knex = require('../lib/knex');
+const config = require('config');
+const { enforce } = require('../lib/helpers');
+const dtHelpers = require('../lib/dt-helpers');
+const interoperableErrors = require('../shared/interoperable-errors');
+
+const entityTypes = {
+ reportTemplate: {
+ entitiesTable: 'report_templates',
+ sharesTable: 'shares_report_template',
+ permissionsTable: 'permissions_report_template'
+ }
+};
+
+function getEntityType(entityTypeId) {
+ const entityType = entityTypes[entityTypeId];
+
+ if (!entityType) {
+ throw new Error(`Unknown entity type ${entityTypeId}`);
+ }
+
+ return entityType
+}
+
+async function listDTAjax(entityTypeId, entityId, params) {
+ const entityType = getEntityType(entityTypeId);
+ return await dtHelpers.ajaxList(params, tx => tx(entityType.sharesTable).innerJoin('users', entityType.sharesTable + '.user', 'users.id'), [entityType.sharesTable + '.id', 'users.username', 'users.name', entityType.sharesTable + '.role', 'users.id']);
+}
+
+async function listUnassignedUsersDTAjax(entityTypeId, entityId, params) {
+ const entityType = getEntityType(entityTypeId);
+ return await dtHelpers.ajaxList(
+ params,
+ tx => tx('users').whereNotExists(function() { return this.select('*').from(entityType.sharesTable).whereRaw(`users.id = ${entityType.sharesTable}.user`); }),
+ ['users.id', 'users.username', 'users.name']);
+}
+
+
+async function assign(entityTypeId, entityId, userId, role) {
+ const entityType = getEntityType(entityTypeId);
+ await knex.transaction(async tx => {
+ enforce(await tx('users').where('id', userId).select('id').first(), 'Invalid user id');
+ enforce(await tx(entityType.entitiesTable).where('id', entityId).select('id').first(), 'Invalid entity id');
+
+ const entry = await tx(entityType.sharesTable).where({user: userId, entity: entityId}).select('id', 'role').first();
+
+ if (entry) {
+ if (!role) {
+ await tx(entityType.sharesTable).where('id', entry.id).del();
+ } else if (entry.role !== role) {
+ await tx(entityType.sharesTable).where('id', entry.id).update('role', role);
+ }
+ } else {
+ await tx(entityType.sharesTable).insert({
+ user: userId,
+ entity: entityId,
+ role
+ });
+ }
+
+ await tx(entityType.permissionsTable).where({user: userId, entity: entityId}).del();
+ if (role) {
+ const permissions = config.roles[entityTypeId][role].permissions;
+ const data = permissions.map(operation => ({user: userId, entity: entityId, operation}));
+ await tx(entityType.permissionsTable).insert(data);
+ }
+ });
+}
+
+async function rebuildPermissions() {
+ await knex.transaction(async tx => {
+ for (const entityTypeId in entityTypes) {
+ const entityType = entityTypes[entityTypeId];
+
+ await tx(entityType.permissionsTable).del();
+
+ const shares = await tx(entityType.sharesTable).select(['entity', 'user', 'role']);
+ for (const share in shares) {
+ const permissions = config.roles[entityTypeId][share.role].permissions;
+ const data = permissions.map(operation => ({user: share.user, entity: share.entity, operation}));
+ await tx(entityType.permissionsTable).insert(data);
+ }
+ }
+ });
+}
+
+module.exports = {
+ listDTAjax,
+ listUnassignedUsersDTAjax,
+ assign,
+ rebuildPermissions
+};
\ No newline at end of file
diff --git a/routes/rest/shares.js b/routes/rest/shares.js
new file mode 100644
index 00000000..acd3ad84
--- /dev/null
+++ b/routes/rest/shares.js
@@ -0,0 +1,27 @@
+'use strict';
+
+const passport = require('../../lib/passport');
+const _ = require('../../lib/translate')._;
+const shares = require('../../models/shares');
+const permissions = require('../../lib/permissions')
+
+const router = require('../../lib/router-async').create();
+
+router.postAsync('/shares-table/:entityTypeId/:entityId', passport.loggedIn, async (req, res) => {
+ return res.json(await shares.listDTAjax(req.params.entityTypeId, req.params.entityId, req.body));
+});
+
+router.postAsync('/shares-users-table/:entityTypeId/:entityId', passport.loggedIn, async (req, res) => {
+ return res.json(await shares.listUnassignedUsersDTAjax(req.params.entityTypeId, req.params.entityId, req.body));
+});
+
+router.putAsync('/shares', passport.loggedIn, async (req, res) => {
+ // FIXME: Check that the user has the right to assign the role
+
+ const body = req.body;
+ await shares.assign(body.entityTypeId, body.entityId, body.userId, body.role);
+
+ return res.json();
+});
+
+module.exports = router;
\ No newline at end of file
diff --git a/routes/rest/users.js b/routes/rest/users.js
index fdb03819..2ea8deb6 100644
--- a/routes/rest/users.js
+++ b/routes/rest/users.js
@@ -39,5 +39,4 @@ router.postAsync('/users-table', passport.loggedIn, async (req, res) => {
return res.json(await users.listDTAjax(req.body));
});
-
module.exports = router;
\ No newline at end of file
diff --git a/setup/knex/migrations/20170507084114_create_permissions.js b/setup/knex/migrations/20170507084114_create_permissions.js
index 339ab6d0..2acdac77 100644
--- a/setup/knex/migrations/20170507084114_create_permissions.js
+++ b/setup/knex/migrations/20170507084114_create_permissions.js
@@ -5,14 +5,6 @@ exports.up = function(knex, Promise) {
table.increments('id').primary();
table.integer('entity').unsigned().notNullable().references('lists.id').onDelete('CASCADE');
table.integer('user').unsigned().notNullable().references('users.id').onDelete('CASCADE');
- table.integer('role', 64).notNullable();
- table.unique(['entity', 'user']);
- })
-
- .createTable('shares_namespace', table => {
- table.increments('id').primary();
- table.integer('entity').unsigned().notNullable().references('namespaces.id').onDelete('CASCADE');
- table.integer('user').unsigned().notNullable().references('users.id').onDelete('CASCADE');
table.string('role', 64).notNullable();
table.unique(['entity', 'user']);
})
@@ -25,6 +17,30 @@ exports.up = function(knex, Promise) {
table.unique(['entity', 'user', 'operation']);
})
+ .createTable('shares_report_template', table => {
+ table.increments('id').primary();
+ table.integer('entity').unsigned().notNullable().references('report_templates.id').onDelete('CASCADE');
+ table.integer('user').unsigned().notNullable().references('users.id').onDelete('CASCADE');
+ table.string('role', 64).notNullable();
+ table.unique(['entity', 'user']);
+ })
+
+ .createTable('permissions_report_template', table => {
+ table.increments('id').primary();
+ table.integer('entity').unsigned().notNullable().references('report_templates.id').onDelete('CASCADE');
+ table.integer('user').unsigned().notNullable().references('users.id').onDelete('CASCADE');
+ table.string('operation', 64).notNullable();
+ table.unique(['entity', 'user', 'operation']);
+ })
+
+ .createTable('shares_namespace', table => {
+ table.increments('id').primary();
+ table.integer('entity').unsigned().notNullable().references('namespaces.id').onDelete('CASCADE');
+ table.integer('user').unsigned().notNullable().references('users.id').onDelete('CASCADE');
+ table.string('role', 64).notNullable();
+ table.unique(['entity', 'user']);
+ })
+
.createTable('permissions_namespace', table => {
table.increments('id').primary();
table.integer('entity').unsigned().notNullable().references('namespaces.id').onDelete('CASCADE');
diff --git a/views/layout.hbs b/views/layout.hbs
index 84496256..b566646b 100644
--- a/views/layout.hbs
+++ b/views/layout.hbs
@@ -77,38 +77,36 @@
{{/each}}
{{#if admin }}
-
+
+ Administration
+
+
{{/if}}
{{#translate}}Wiki{{/translate}}