Share report template functionality

This commit is contained in:
Tomas Bures 2017-07-24 07:03:32 +03:00
parent e6ad0e239e
commit 4822a50d0b
35 changed files with 614 additions and 128 deletions

2
app.js
View file

@ -50,6 +50,7 @@ const reportTemplatesRest = require('./routes/rest/report-templates');
const reportsRest = require('./routes/rest/reports'); const reportsRest = require('./routes/rest/reports');
const campaignsRest = require('./routes/rest/campaigns'); const campaignsRest = require('./routes/rest/campaigns');
const listsRest = require('./routes/rest/lists'); const listsRest = require('./routes/rest/lists');
const sharesRest = require('./routes/rest/shares');
const namespacesLegacyIntegration = require('./routes/namespaces-legacy-integration'); const namespacesLegacyIntegration = require('./routes/namespaces-legacy-integration');
const usersLegacyIntegration = require('./routes/users-legacy-integration'); const usersLegacyIntegration = require('./routes/users-legacy-integration');
@ -271,6 +272,7 @@ app.use('/rest', usersRest);
app.use('/rest', accountRest); app.use('/rest', accountRest);
app.use('/rest', campaignsRest); app.use('/rest', campaignsRest);
app.use('/rest', listsRest); app.use('/rest', listsRest);
app.use('/rest', sharesRest);
if (config.reports && config.reports.enabled === true) { if (config.reports && config.reports.enabled === true) {
app.use('/rest', reportTemplatesRest); app.use('/rest', reportTemplatesRest);

View file

@ -2,7 +2,7 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { translate, Trans } from 'react-i18next'; 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 { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import URL from 'url-parse'; import URL from 'url-parse';
import axios from '../lib/axios'; import axios from '../lib/axios';
@ -11,6 +11,7 @@ import { Button } from '../lib/bootstrap-components';
@translate() @translate()
@withPageHelpers @withPageHelpers
@withErrorHandling @withErrorHandling
@requiresAuthenticatedUser
export default class API extends Component { export default class API extends Component {
constructor(props) { constructor(props) {
super(props); super(props);

View file

@ -2,7 +2,7 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { translate, Trans } from 'react-i18next'; import { translate, Trans } from 'react-i18next';
import { withPageHelpers, Title } from '../lib/page' import { requiresAuthenticatedUser, withPageHelpers, Title } from '../lib/page'
import { import {
withForm, Form, Fieldset, FormSendMethod, InputField, ButtonRow, Button withForm, Form, Fieldset, FormSendMethod, InputField, ButtonRow, Button
} from '../lib/form'; } from '../lib/form';
@ -15,6 +15,7 @@ import mailtrainConfig from 'mailtrainConfig';
@withForm @withForm
@withPageHelpers @withPageHelpers
@withErrorHandling @withErrorHandling
@requiresAuthenticatedUser
export default class Account extends Component { export default class Account extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
@ -120,6 +121,8 @@ export default class Account extends Component {
this.updateFormValue('currentPassword', ''); this.updateFormValue('currentPassword', '');
this.clearFormStatusMessage(); this.clearFormStatusMessage();
this.enableForm();
} else { } else {
this.enableForm(); this.enableForm();
this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.')); this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.'));

View file

@ -4,7 +4,7 @@ import React, { Component } from 'react';
import { translate } from 'react-i18next'; import { translate } from 'react-i18next';
import { withPageHelpers, Title } from '../lib/page' import { withPageHelpers, Title } from '../lib/page'
import { import {
withForm, Form, FormSendMethod, InputField, CheckBox, ButtonRow, Button, AlignedRow withForm, Form, FormSendMethod, InputField, ButtonRow, Button
} from '../lib/form'; } from '../lib/form';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling'; import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';

View file

@ -59,6 +59,8 @@ export default class Login extends Component {
this.setFormStatusMessage('info', t('Verifying credentials ...')); this.setFormStatusMessage('info', t('Verifying credentials ...'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(FormSendMethod.POST, '/rest/login'); 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) { if (submitSuccessful) {
const query = new URL(this.props.location.search, true).query; const query = new URL(this.props.location.search, true).query;

View file

@ -5,12 +5,12 @@ import ReactDOM from 'react-dom';
import { I18nextProvider } from 'react-i18next'; import { I18nextProvider } from 'react-i18next';
import i18n from '../lib/i18n'; import i18n from '../lib/i18n';
import { Section } from '../lib/page' import { Section } from '../lib/page';
import Account from './Account' import Account from './Account';
import Login from './Login' import Login from './Login';
import Reset from './Forgot' import Reset from './Forgot';
import ResetLink from './Reset' import ResetLink from './Reset';
import API from './API' import API from './API';
import mailtrainConfig from 'mailtrainConfig'; import mailtrainConfig from 'mailtrainConfig';

View file

@ -23,7 +23,7 @@ function handleError(that, error) {
function withErrorHandling(target) { function withErrorHandling(target) {
const inst = target.prototype; const inst = target.prototype;
if (inst._withErrorHandlingApplied) return; if (inst._withErrorHandlingApplied) return target;
inst._withErrorHandlingApplied = true; inst._withErrorHandlingApplied = true;
const contextTypes = target.contextTypes || {}; const contextTypes = target.contextTypes || {};

View file

@ -356,7 +356,7 @@ class TreeTableSelect extends Component {
} }
} }
@translate() @translate(null, { withRef: true })
class TableSelect extends Component { class TableSelect extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
@ -369,6 +369,7 @@ class TableSelect extends Component {
static propTypes = { static propTypes = {
dataUrl: PropTypes.string, dataUrl: PropTypes.string,
data: PropTypes.array,
columns: PropTypes.array, columns: PropTypes.array,
selectionKeyIndex: PropTypes.number, selectionKeyIndex: PropTypes.number,
selectionLabelIndex: PropTypes.number, selectionLabelIndex: PropTypes.number,
@ -426,6 +427,10 @@ class TableSelect extends Component {
}); });
} }
refresh() {
this.table.refresh();
}
render() { render() {
const props = this.props; const props = this.props;
const owner = this.context.formStateOwner; const owner = this.context.formStateOwner;
@ -443,7 +448,7 @@ class TableSelect extends Component {
</span> </span>
</div> </div>
<div className={'mt-tableselect-table' + (this.state.open ? '' : ' mt-tableselect-table-hidden')}> <div className={'mt-tableselect-table' + (this.state.open ? '' : ' mt-tableselect-table-hidden')}>
<Table 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}/> <Table ref={node => 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}/>
</div> </div>
</div> </div>
); );
@ -451,7 +456,7 @@ class TableSelect extends Component {
return wrapInput(id, htmlId, owner, props.label, props.help, return wrapInput(id, htmlId, owner, props.label, props.help,
<div> <div>
<div> <div>
<Table dataUrl={props.dataUrl} columns={props.columns} selectMode={props.selectMode} selectionAsArray={this.props.selectionAsArray} withHeader={props.withHeader} selection={owner.getFormValue(id)} onSelectionChangedAsync={::this.onSelectionChangedAsync}/> <Table ref={node => 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}/>
</div> </div>
</div> </div>
); );
@ -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 { class ACEEditor extends Component {
static propTypes = { static propTypes = {
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,

View file

@ -38,4 +38,9 @@
.mt-tableselect-dropdown input[readonly] { .mt-tableselect-dropdown input[readonly] {
background-color: white; background-color: white;
}
h3.legend {
font-size: 21px;
margin-bottom: 20px;
} }

View file

@ -9,6 +9,7 @@ import './page.css';
import { withErrorHandling } from './error-handling'; import { withErrorHandling } from './error-handling';
import interoperableErrors from '../../../shared/interoperable-errors'; import interoperableErrors from '../../../shared/interoperable-errors';
import { DismissibleAlert, Button } from './bootstrap-components'; import { DismissibleAlert, Button } from './bootstrap-components';
import mailtrainConfig from 'mailtrainConfig';
class PageContent extends Component { class PageContent extends Component {
@ -200,10 +201,16 @@ class SectionContent extends Component {
this.setFlashMessage(severity, text); this.setFlashMessage(severity, text);
} }
errorHandler(error) { ensureAuthenticated() {
if (error instanceof interoperableErrors.NotLoggedInError) { if (!mailtrainConfig.isAuthenticated) {
/* FIXME, once we turn Mailtrain to single-page application, this should become navigateTo */ /* FIXME, once we turn Mailtrain to single-page application, this should become navigateTo */
window.location = '/account/login?next=' + encodeURIComponent(this.props.root); 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) { } else if (error.response && error.response.data && error.response.data.message) {
console.error(error); console.error(error);
this.navigateToWithFlashMessage(this.props.root, 'danger', error.response.data.message); this.navigateToWithFlashMessage(this.props.root, 'danger', error.response.data.message);
@ -312,7 +319,7 @@ class DropdownLink extends Component {
} }
function withPageHelpers(target) { function withPageHelpers(target) {
withErrorHandling(target); target = withErrorHandling(target);
const inst = target.prototype; const inst = target.prototype;
@ -346,16 +353,32 @@ function withPageHelpers(target) {
return this.context.sectionContent.navigateToWithFlashMessage(path, severity, text); return this.context.sectionContent.navigateToWithFlashMessage(path, severity, text);
} }
inst.axios
return target; 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 { export {
Section, Section,
Title, Title,
Toolbar, Toolbar,
NavButton, NavButton,
DropdownLink, DropdownLink,
withPageHelpers withPageHelpers,
requiresAuthenticatedUser
}; };

View file

@ -6,7 +6,6 @@ import { translate } from 'react-i18next';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import jQuery from 'jquery'; import jQuery from 'jquery';
import '../../public/jquery/jquery-ui-1.12.1.min.js';
import 'datatables.net'; import 'datatables.net';
import 'datatables.net-bs'; import 'datatables.net-bs';
@ -82,13 +81,20 @@ class Table extends Component {
selMap.set(elem, undefined); selMap.set(elem, undefined);
} }
if (this.table) { if (props.data) {
const self = this; for (const rowData of props.data) {
this.table.rows().every(function() { const key = rowData[props.selectionKeyIndex];
const data = this.data();
const key = data[self.props.selectionKeyIndex];
if (selMap.has(key)) { 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 @withAsyncErrorHandler
async fetchSelectionData() { async fetchAndNotifySelectionData() {
if (this.props.onSelectionDataAsync) { if (this.props.onSelectionDataAsync) {
const keysToFetch = []; if (!this.props.data) {
for (const pair of this.selectionMap.entries()) { const keysToFetch = [];
if (!pair[1]) { for (const pair of this.selectionMap.entries()) {
keysToFetch.push(pair[0]); if (!pair[1]) {
keysToFetch.push(pair[0]);
}
} }
}
if (keysToFetch.length > 0) { if (keysToFetch.length > 0) {
const response = await axios.post(this.props.dataUrl, { const response = await axios.post(this.props.dataUrl, {
operation: 'getBy', operation: 'getBy',
column: this.props.selectionKeyIndex, column: this.props.selectionKeyIndex,
values: keysToFetch values: keysToFetch
}); });
for (const row of response.data) { for (const row of response.data) {
const key = row[this.props.selectionKeyIndex]; const key = row[this.props.selectionKeyIndex];
if (this.selectionMap.has(key)) { if (this.selectionMap.has(key)) {
this.selectionMap.set(key, row); this.selectionMap.set(key, row);
}
} }
} }
} }
@ -293,7 +301,7 @@ class Table extends Component {
clearTimeout(this.refreshTimeoutId); clearTimeout(this.refreshTimeoutId);
}); });
this.fetchSelectionData(); this.fetchAndNotifySelectionData();
} }
componentDidUpdate() { componentDidUpdate() {
@ -314,7 +322,7 @@ class Table extends Component {
} }
this.updateSelectInfo(); this.updateSelectInfo();
this.fetchSelectionData(); this.fetchAndNotifySelectionData();
} }
async notifySelection(eventCallback, newSelectionMap) { async notifySelection(eventCallback, newSelectionMap) {

View file

@ -1,8 +1,9 @@
'use strict'; 'use strict';
import React, { Component } from 'react'; import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { translate } from 'react-i18next'; 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 { withForm, Form, FormSendMethod, InputField, TextArea, ButtonRow, Button, TreeTableSelect } from '../lib/form';
import axios from '../lib/axios'; import axios from '../lib/axios';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling'; import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
@ -13,6 +14,7 @@ import { ModalDialog } from '../lib/bootstrap-components';
@withForm @withForm
@withPageHelpers @withPageHelpers
@withErrorHandling @withErrorHandling
@requiresAuthenticatedUser
export default class CUD extends Component { export default class CUD extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
@ -28,6 +30,10 @@ export default class CUD extends Component {
} }
static propTypes = {
edit: PropTypes.bool
}
isEditGlobal() { isEditGlobal() {
return this.state.entityId === 1; return this.state.entityId === 1;
} }

View file

@ -2,11 +2,17 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { translate } from 'react-i18next'; 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'; import { TreeTable } from '../lib/tree';
@translate() @translate()
@withPageHelpers
@requiresAuthenticatedUser
export default class List extends Component { export default class List extends Component {
constructor(props) {
super(props);
}
render() { render() {
const t = this.props.t; const t = this.props.t;

View file

@ -5,9 +5,9 @@ import ReactDOM from 'react-dom';
import { I18nextProvider } from 'react-i18next'; import { I18nextProvider } from 'react-i18next';
import i18n from '../lib/i18n'; import i18n from '../lib/i18n';
import { Section } from '../lib/page' import { Section } from '../lib/page';
import CUD from './CUD' import CUD from './CUD';
import List from './List' import List from './List';
const getStructure = t => ({ const getStructure = t => ({
'': { '': {

View file

@ -1,8 +1,9 @@
'use strict'; 'use strict';
import React, { Component } from 'react'; import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { translate, Trans } from 'react-i18next'; import { translate, Trans } from 'react-i18next';
import { withPageHelpers, Title } from '../lib/page' import { requiresAuthenticatedUser, withPageHelpers, Title } from '../lib/page';
import { import {
withForm, Form, FormSendMethod, InputField, TextArea, TableSelect, TableSelectMode, ButtonRow, Button, withForm, Form, FormSendMethod, InputField, TextArea, TableSelect, TableSelectMode, ButtonRow, Button,
Fieldset Fieldset
@ -16,6 +17,7 @@ import moment from 'moment';
@withForm @withForm
@withPageHelpers @withPageHelpers
@withErrorHandling @withErrorHandling
@requiresAuthenticatedUser
export default class CUD extends Component { export default class CUD extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
@ -33,6 +35,10 @@ export default class CUD extends Component {
}); });
} }
static propTypes = {
edit: PropTypes.bool
}
isDelete() { isDelete() {
return this.props.match.params.action === 'delete'; return this.props.match.params.action === 'delete';
} }

View file

@ -2,7 +2,7 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { translate } from 'react-i18next'; 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 { Table } from '../lib/table';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling'; import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import moment from 'moment'; import moment from 'moment';
@ -11,6 +11,7 @@ import { ReportState } from '../../../shared/reports';
@translate() @translate()
@withErrorHandling @withErrorHandling
@withPageHelpers
export default class List extends Component { export default class List extends Component {
@withAsyncErrorHandler @withAsyncErrorHandler

View file

@ -2,13 +2,14 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { translate } from 'react-i18next'; 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 { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import axios from '../lib/axios'; import axios from '../lib/axios';
@translate() @translate()
@withPageHelpers @withPageHelpers
@withErrorHandling @withErrorHandling
@requiresAuthenticatedUser
export default class Output extends Component { export default class Output extends Component {
constructor(props) { constructor(props) {
super(props); super(props);

View file

@ -2,7 +2,7 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { translate } from 'react-i18next'; 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 { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import axios from '../lib/axios'; import axios from '../lib/axios';
import { ReportState } from '../../../shared/reports'; import { ReportState } from '../../../shared/reports';
@ -10,6 +10,7 @@ import { ReportState } from '../../../shared/reports';
@translate() @translate()
@withPageHelpers @withPageHelpers
@withErrorHandling @withErrorHandling
@requiresAuthenticatedUser
export default class View extends Component { export default class View extends Component {
constructor(props) { constructor(props) {
super(props); super(props);

View file

@ -5,13 +5,15 @@ import ReactDOM from 'react-dom';
import { I18nextProvider } from 'react-i18next'; import { I18nextProvider } from 'react-i18next';
import i18n from '../lib/i18n'; import i18n from '../lib/i18n';
import { Section } from '../lib/page' import { Section } from '../lib/page';
import ReportsCUD from './CUD' import ReportsCUD from './CUD';
import ReportsList from './List' import ReportsList from './List';
import ReportsView from './View' import ReportsView from './View';
import ReportsOutput from './Output' import ReportsOutput from './Output';
import ReportTemplatesCUD from './templates/CUD' import ReportTemplatesCUD from './templates/CUD';
import ReportTemplatesList from './templates/List' import ReportTemplatesList from './templates/List';
import Share from '../shares/Share';
const getStructure = t => { const getStructure = t => {
const subPaths = {}; const subPaths = {};
@ -59,11 +61,16 @@ const getStructure = t => {
title: t('Create Report Template'), title: t('Create Report Template'),
params: [':wizard?'], params: [':wizard?'],
render: props => (<ReportTemplatesCUD {...props} />) render: props => (<ReportTemplatesCUD {...props} />)
},
share: {
title: t('Share Report Template'),
params: [':id'],
render: props => (<Share title={entity => t('Share Report Template "{{name}}"', {name: entity.name})} getUrl={id => `/rest/report-templates/${id}`} entityTypeId="reportTemplate" {...props} />)
} }
} }
} }
} }
}, }
} }
} }
} }

View file

@ -1,6 +1,7 @@
'use strict'; 'use strict';
import React, { Component } from 'react'; import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { translate, Trans } from 'react-i18next'; import { translate, Trans } from 'react-i18next';
import { withPageHelpers, Title } from '../../lib/page' import { withPageHelpers, Title } from '../../lib/page'
import { withForm, Form, FormSendMethod, InputField, TextArea, Dropdown, ACEEditor, ButtonRow, Button } from '../../lib/form'; 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(); this.initForm();
} }
static propTypes = {
edit: PropTypes.bool
}
isDelete() { isDelete() {
return this.props.match.params.action === 'delete'; return this.props.match.params.action === 'delete';
} }

View file

@ -3,12 +3,18 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { translate } from 'react-i18next'; import { translate } from 'react-i18next';
import { DropdownMenu } from '../../lib/bootstrap-components'; 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 { Table } from '../../lib/table';
import moment from 'moment'; import moment from 'moment';
@translate() @translate()
@withPageHelpers
@requiresAuthenticatedUser
export default class List extends Component { export default class List extends Component {
constructor(props) {
super(props);
}
render() { render() {
const t = this.props.t; const t = this.props.t;
@ -16,6 +22,10 @@ export default class List extends Component {
{ {
label: 'Edit', label: 'Edit',
link: '/reports/templates/edit/' + data[0] link: '/reports/templates/edit/' + data[0]
},
{
label: 'Share',
link: '/reports/templates/share/' + data[0]
} }
]; ];

179
client/src/shares/Share.js Normal file
View file

@ -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 (
<div>
<Title>{this.props.title(this.state.entity)}</Title>
<h3 className="legend">{t('Add User')}</h3>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<TableSelect ref={node => 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}/>
<TableSelect id="role" label={t('Role')} withHeader dropdown data={rolesData} columns={rolesColumns} selectionLabelIndex={1}/>
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Share')}/>
</ButtonRow>
</Form>
<hr/>
<h3 className="legend">{t('Existing Users')}</h3>
<Table ref={node => this.sharesTable = node} withHeader dataUrl={`/rest/shares-table/${this.props.entityTypeId}/${this.state.entityId}`} columns={sharesColumns} actions={actions}/>
</div>
);
} else {
return (<p>{t('Loading ...')}</p>)
}
}
}

View file

@ -1,8 +1,9 @@
'use strict'; 'use strict';
import React, { Component } from 'react'; import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { translate } from 'react-i18next'; 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 { withForm, Form, FormSendMethod, InputField, ButtonRow, Button } from '../lib/form';
import axios from '../lib/axios'; import axios from '../lib/axios';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling'; import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
@ -16,6 +17,7 @@ import mailtrainConfig from 'mailtrainConfig';
@withForm @withForm
@withPageHelpers @withPageHelpers
@withErrorHandling @withErrorHandling
@requiresAuthenticatedUser
export default class CUD extends Component { export default class CUD extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
@ -37,6 +39,10 @@ export default class CUD extends Component {
}); });
} }
static propTypes = {
edit: PropTypes.bool
}
isDelete() { isDelete() {
return this.props.match.params.action === 'delete'; return this.props.match.params.action === 'delete';
} }

View file

@ -2,12 +2,18 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { translate } from 'react-i18next'; 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 { Table } from '../lib/table';
import mailtrainConfig from 'mailtrainConfig'; import mailtrainConfig from 'mailtrainConfig';
@translate() @translate()
@withPageHelpers
@requiresAuthenticatedUser
export default class List extends Component { export default class List extends Component {
constructor(props) {
super(props);
}
render() { render() {
const t = this.props.t; const t = this.props.t;

View file

@ -5,9 +5,9 @@ import ReactDOM from 'react-dom';
import { I18nextProvider } from 'react-i18next'; import { I18nextProvider } from 'react-i18next';
import i18n from '../lib/i18n'; import i18n from '../lib/i18n';
import { Section } from '../lib/page' import { Section } from '../lib/page';
import CUD from './CUD' import CUD from './CUD';
import List from './List' import List from './List';
import mailtrainConfig from 'mailtrainConfig'; import mailtrainConfig from 'mailtrainConfig';
const getStructure = t => { const getStructure = t => {

View file

@ -188,16 +188,21 @@ logger=false
browser="phantomjs" browser="phantomjs"
[roles.list.master] [roles.reportTemplate.master]
name="Master" name="Master"
description="All permissions" description="All permissions"
permissions=["view"] permissions=["view", "edit", "delete"]
[roles.namespace.master] #[roles.list.master]
name="Master" #name="Master"
description="All permissions" #description="All permissions"
permissions=["view", "edit", "create", "delete", "create list"] #permissions=["view"]
#
[roles.namespace.master.childperms] #[roles.namespace.master]
list=["view"] #name="Master"
namespace=["view", "edit", "create", "delete", "create list"] #description="All permissions"
#permissions=["view", "edit", "create", "delete", "create list"]
#
#[roles.namespace.master.childperms]
#list=["view"]
#namespace=["view", "edit", "create", "delete", "create list"]

View file

@ -2,29 +2,58 @@
const passport = require('./passport'); const passport = require('./passport');
const config = require('config'); const config = require('config');
const permissions = require('./permissions');
function _getConfig(context) { function getAnonymousConfig(context) {
return { return {
authMethod: passport.authMethod, authMethod: passport.authMethod,
isAuthMethodLocal: passport.isAuthMethodLocal, isAuthMethodLocal: passport.isAuthMethodLocal,
externalPasswordResetLink: config.ldap.passwordresetlink, externalPasswordResetLink: config.ldap.passwordresetlink,
language: config.language || 'en', 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) { function registerRootRoute(router, entryPoint, title) {
router.get('/*', passport.csrfProtection, (req, res) => { router.get('/*', passport.csrfProtection, (req, res) => {
const mailtrainConfig = getAnonymousConfig(req.context);
if (req.user) {
Object.assign(mailtrainConfig, getAuthenticatedConfig(req.context));
}
res.render('react-root', { res.render('react-root', {
title, title,
reactEntryPoint: entryPoint, reactEntryPoint: entryPoint,
reactCsrfToken: req.csrfToken(), reactCsrfToken: req.csrfToken(),
mailtrainConfig: JSON.stringify(_getConfig(req.context)) mailtrainConfig: JSON.stringify(mailtrainConfig)
}); });
}); });
} }
module.exports = { module.exports = {
registerRootRoute registerRootRoute,
getAuthenticatedConfig
}; };

View file

@ -8,6 +8,7 @@ const knex = require('knex')({
migrations: { migrations: {
directory: __dirname + '/../setup/knex/migrations' directory: __dirname + '/../setup/knex/migrations'
} }
// , debug: true
}); });
knex.migrate.latest(); knex.migrate.latest();

View file

@ -2,13 +2,35 @@
const config = require('config'); 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) { constructor(name) {
this.name = name; this.name = name;
this.entityType = 'list'; this.entityType = 'list';
} }
} }
const ListPermissions = {
View: new ListPermissions('view')
};
class NamespacePermission { class NamespacePermission {
constructor(name) { constructor(name) {
this.name = name; this.name = name;
@ -16,10 +38,6 @@ class NamespacePermission {
} }
} }
const ListPermissions = {
View: new ListPermissions('view')
};
const NamespacePermissions = { const NamespacePermissions = {
View: new NamespacePermission('view'), View: new NamespacePermission('view'),
Edit: new NamespacePermission('edit'), Edit: new NamespacePermission('edit'),
@ -27,7 +45,9 @@ const NamespacePermissions = {
Delete: new NamespacePermission('delete'), Delete: new NamespacePermission('delete'),
CreateList: new NamespacePermission('create list') CreateList: new NamespacePermission('create list')
}; };
*/
/*
async function can(context, operation, entityId) { async function can(context, operation, entityId) {
if (!context.user) { if (!context.user) {
return false; return false;
@ -48,3 +68,8 @@ async function buildPermissions() {
can(ctx, ListPermissions.View, 3) can(ctx, ListPermissions.View, 3)
can(ctx, NamespacePermissions.CreateList, 2) can(ctx, NamespacePermissions.CreateList, 2)
can(ctx, ReportTemplatePermissions.ViewReport, 5)
*/
module.exports = {
}

View file

@ -2,7 +2,7 @@
const _ = require('./translate')._; const _ = require('./translate')._;
const util = require('util'); const util = require('util');
const isemail = require('isemail') const isemail = require('isemail');
module.exports = { module.exports = {
validateEmail validateEmail

94
models/shares.js Normal file
View file

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

27
routes/rest/shares.js Normal file
View file

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

View file

@ -39,5 +39,4 @@ router.postAsync('/users-table', passport.loggedIn, async (req, res) => {
return res.json(await users.listDTAjax(req.body)); return res.json(await users.listDTAjax(req.body));
}); });
module.exports = router; module.exports = router;

View file

@ -5,14 +5,6 @@ exports.up = function(knex, Promise) {
table.increments('id').primary(); table.increments('id').primary();
table.integer('entity').unsigned().notNullable().references('lists.id').onDelete('CASCADE'); table.integer('entity').unsigned().notNullable().references('lists.id').onDelete('CASCADE');
table.integer('user').unsigned().notNullable().references('users.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.string('role', 64).notNullable();
table.unique(['entity', 'user']); table.unique(['entity', 'user']);
}) })
@ -25,6 +17,30 @@ exports.up = function(knex, Promise) {
table.unique(['entity', 'user', 'operation']); 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 => { .createTable('permissions_namespace', table => {
table.increments('id').primary(); table.increments('id').primary();
table.integer('entity').unsigned().notNullable().references('namespaces.id').onDelete('CASCADE'); table.integer('entity').unsigned().notNullable().references('namespaces.id').onDelete('CASCADE');

View file

@ -77,38 +77,36 @@
{{/each}} {{/each}}
{{#if admin }} {{#if admin }}
<ul class="nav navbar-nav"> <li class="dropdown">
<li class="dropdown"> <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Administration<span class="caret"></span></a>
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Administration<span class="caret"></span></a> <ul class="dropdown-menu">
<ul class="dropdown-menu"> <li>
<li> <a href="/users">
<a href="/users"> <span class="glyphicon glyphicon-cog" aria-hidden="true"></span> {{#translate}}Users{{/translate}}
<span class="glyphicon glyphicon-cog" aria-hidden="true"></span> {{#translate}}Users{{/translate}} </a>
</a> </li>
</li> <li>
<li> <a href="/namespaces">
<a href="/namespaces"> <span class="glyphicon glyphicon-cog" aria-hidden="true"></span> {{#translate}}Namespaces{{/translate}}
<span class="glyphicon glyphicon-cog" aria-hidden="true"></span> {{#translate}}Namespaces{{/translate}} </a>
</a> </li>
</li> <li>
<li> <a href="/settings">
<a href="/settings"> <span class="glyphicon glyphicon-cog" aria-hidden="true"></span> {{#translate}}Settings{{/translate}}
<span class="glyphicon glyphicon-cog" aria-hidden="true"></span> {{#translate}}Settings{{/translate}} </a>
</a> </li>
</li> <li>
<li> <a href="/blacklist">
<a href="/blacklist"> <span class="glyphicon glyphicon-ban-circle" aria-hidden="true"></span> {{#translate}}Blacklist{{/translate}}
<span class="glyphicon glyphicon-ban-circle" aria-hidden="true"></span> {{#translate}}Blacklist{{/translate}} </a>
</a> </li>
</li> <li>
<li> <a href="/account/api">
<a href="/account/api"> <span class="glyphicon glyphicon-retweet" aria-hidden="true"></span> {{#translate}}API{{/translate}}
<span class="glyphicon glyphicon-retweet" aria-hidden="true"></span> {{#translate}}API{{/translate}} </a>
</a> </li>
</li> </ul>
</ul> </li>
</li>
</ul>
{{/if}} {{/if}}
<li><a href="https://github.com/Mailtrain-org/mailtrain/wiki"><span class="glyphicon glyphicon-share-alt" aria-hidden="true"></span> {{#translate}}Wiki{{/translate}}</a></li> <li><a href="https://github.com/Mailtrain-org/mailtrain/wiki"><span class="glyphicon glyphicon-share-alt" aria-hidden="true"></span> {{#translate}}Wiki{{/translate}}</a></li>