From 4822a50d0b0ff16ecb069c964df39bb147dc9d5e Mon Sep 17 00:00:00 2001 From: Tomas Bures Date: Mon, 24 Jul 2017 07:03:32 +0300 Subject: [PATCH] Share report template functionality --- app.js | 2 + client/src/account/API.js | 3 +- client/src/account/Account.js | 5 +- client/src/account/Forgot.js | 2 +- client/src/account/Login.js | 2 + client/src/account/root.js | 12 +- client/src/lib/error-handling.js | 2 +- client/src/lib/form.js | 20 +- client/src/lib/page.css | 5 + client/src/lib/page.js | 35 +++- client/src/lib/table.js | 58 +++--- client/src/namespaces/CUD.js | 8 +- client/src/namespaces/List.js | 8 +- client/src/namespaces/root.js | 6 +- client/src/reports/CUD.js | 8 +- client/src/reports/List.js | 3 +- client/src/reports/Output.js | 3 +- client/src/reports/View.js | 3 +- client/src/reports/root.js | 23 ++- client/src/reports/templates/CUD.js | 5 + client/src/reports/templates/List.js | 12 +- client/src/shares/Share.js | 179 ++++++++++++++++++ client/src/users/CUD.js | 8 +- client/src/users/List.js | 8 +- client/src/users/root.js | 6 +- config/default.toml | 25 ++- lib/client-helpers.js | 37 +++- lib/knex.js | 1 + lib/permissions.js | 35 +++- lib/tools-async.js | 2 +- models/shares.js | 94 +++++++++ routes/rest/shares.js | 27 +++ routes/rest/users.js | 1 - .../20170507084114_create_permissions.js | 32 +++- views/layout.hbs | 62 +++--- 35 files changed, 614 insertions(+), 128 deletions(-) create mode 100644 client/src/shares/Share.js create mode 100644 models/shares.js create mode 100644 routes/rest/shares.js 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')}

+
+ this.usersTableSelect = node} id="userId" label={t('User')} withHeader dropdown dataUrl={`/rest/shares-users-table/${this.props.entityTypeId}/${this.state.entityId}`} columns={usersColumns} selectionLabelIndex={usersLabelIndex}/> + + + +
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 }} - + {{/if}}
  • {{#translate}}Wiki{{/translate}}