Blacklist functionality

Some API improvements
This commit is contained in:
Tomas Bures 2017-09-17 16:36:23 +02:00
parent c343e4efd3
commit 9203b5cee7
40 changed files with 726 additions and 398 deletions

6
app.js
View file

@ -36,7 +36,6 @@ const webhooks = require('./routes/webhooks');
const subscription = require('./routes/subscription');
const archive = require('./routes/archive');
const api = require('./routes/api');
const blacklist = require('./routes/blacklist');
const editorapi = require('./routes/editorapi');
const grapejs = require('./routes/grapejs');
const mosaico = require('./routes/mosaico');
@ -56,12 +55,14 @@ const fieldsRest = require('./routes/rest/fields');
const sharesRest = require('./routes/rest/shares');
const segmentsRest = require('./routes/rest/segments');
const subscriptionsRest = require('./routes/rest/subscriptions');
const blacklistRest = require('./routes/rest/blacklist');
const namespacesLegacyIntegration = require('./routes/namespaces-legacy-integration');
const usersLegacyIntegration = require('./routes/users-legacy-integration');
const accountLegacyIntegration = require('./routes/account-legacy-integration');
const reportsLegacyIntegration = require('./routes/reports-legacy-integration');
const listsLegacyIntegration = require('./routes/lists-legacy-integration');
const blacklistLegacyIntegration = require('./routes/blacklist-legacy-integration');
const interoperableErrors = require('./shared/interoperable-errors');
@ -235,7 +236,6 @@ app.use('/lists', lists);
app.use('/templates', templates);
app.use('/campaigns', campaigns);
app.use('/settings', settings);
app.use('/blacklist', blacklist);
app.use('/links', links);
app.use('/fields', fields);
app.use('/forms', forms);
@ -259,6 +259,7 @@ app.use('/users', usersLegacyIntegration);
app.use('/namespaces', namespacesLegacyIntegration);
app.use('/account', accountLegacyIntegration);
app.use('/lists', listsLegacyIntegration);
app.use('/blacklist', blacklistLegacyIntegration);
if (config.reports && config.reports.enabled === true) {
app.use('/reports', reports);
@ -281,6 +282,7 @@ app.use('/rest', fieldsRest);
app.use('/rest', sharesRest);
app.use('/rest', segmentsRest);
app.use('/rest', subscriptionsRest);
app.use('/rest', blacklistRest);
if (config.reports && config.reports.enabled === true) {
app.use('/rest', reportTemplatesRest);

View file

@ -35,6 +35,8 @@
"react-router-dom": "^4.1.1",
"react-sortable-tree": "^1.2.0",
"slugify": "^1.1.0",
"react-dnd-html5-backend": "^2.4.1",
"react-dnd-touch-backend": "^0.3.13",
"url-parse": "^1.1.9"
},
"devDependencies": {
@ -48,8 +50,6 @@
"css-loader": "^0.28.4",
"i18next-conv": "^3.0.3",
"node-sass": "^4.5.3",
"react-dnd-html5-backend": "^2.4.1",
"react-dnd-touch-backend": "^0.3.13",
"sass-loader": "^6.0.6",
"style-loader": "^0.18.2",
"url-loader": "^0.5.9",

View file

@ -19,12 +19,12 @@ const getStructure = t => {
login: {
title: t('Sign in'),
link: '/account/login',
component: Login,
panelComponent: Login,
},
api: {
title: t('API'),
link: '/account/api',
component: API
panelComponent: API
}
};
@ -33,14 +33,14 @@ const getStructure = t => {
title: t('Password reset'),
extraParams: [':username?'],
link: '/account/forgot',
component: Reset
panelComponent: Reset
};
subPaths.reset = {
title: t('Password reset'),
extraParams: [':username', ':resetToken'],
link: '/account/reset',
component: ResetLink
panelComponent: ResetLink
};
}
@ -52,10 +52,9 @@ const getStructure = t => {
account: {
title: t('Account'),
link: '/account',
component: Account,
panelComponent: Account,
children: subPaths
}
}
}
@ -67,6 +66,6 @@ export default function() {
<I18nextProvider i18n={ i18n }><Section root='/account/login' structure={getStructure}/></I18nextProvider>,
document.getElementById('root')
);
};
}

View file

@ -0,0 +1,129 @@
'use strict';
import React, {Component} from "react";
import {translate} from "react-i18next";
import {requiresAuthenticatedUser, Title, withPageHelpers} from "../lib/page";
import {withAsyncErrorHandler, withErrorHandling} from "../lib/error-handling";
import {Table} from "../lib/table";
import {ButtonRow, Form, InputField, withForm, FormSendMethod} from "../lib/form";
import {Button, Icon} from "../lib/bootstrap-components";
import axios from "../lib/axios";
@translate()
@withForm
@withPageHelpers
@withErrorHandling
@requiresAuthenticatedUser
export default class List extends Component {
constructor(props) {
super(props);
const t = props.t;
this.state = {};
this.initForm({
serverValidation: {
url: '/rest/blacklist-validate',
changed: ['email']
}
});
}
static propTypes = {
}
clearFields() {
this.populateFormValues({
email: ''
});
}
localValidateFormValues(state) {
const t = this.props.t;
const email = state.getIn(['email', 'value']);
const emailServerValidation = state.getIn(['email', 'serverValidation']);
if (!email) {
state.setIn(['email', 'error'], t('Email must not be empty'));
} else if (emailServerValidation && emailServerValidation.invalid) {
state.setIn(['email', 'error'], t('Invalid email address.'));
} else if (emailServerValidation && emailServerValidation.exists) {
state.setIn(['email', 'error'], t('The email is already on blacklist.'));
} else if (!emailServerValidation) {
state.setIn(['email', 'error'], t('Validation is in progress...'));
} else {
state.setIn(['email', 'error'], null);
}
}
async submitHandler() {
const t = this.props.t;
this.disableForm();
this.setFormStatusMessage('info', t('Saving ...'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(FormSendMethod.POST, '/rest/blacklist');
if (submitSuccessful) {
this.hideFormValidation();
this.clearFields();
this.enableForm();
this.clearFormStatusMessage();
this.blacklistTable.refresh();
} else {
this.enableForm();
this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and try again.'));
}
}
componentDidMount() {
this.clearFields();
}
@withAsyncErrorHandler
async deleteBlacklisted(email) {
await axios.delete(`/rest/blacklist/${email}`);
this.blacklistTable.refresh();
}
render() {
const t = this.props.t;
const columns = [
{ data: 0, title: t('Email') },
{
actions: data => [
{
label: <Icon icon="remove" title={t('Remove from blacklist')}/>,
action: () => this.deleteBlacklisted(data[0])
}
]
}
];
return (
<div>
<Title>{t('Blacklist')}</Title>
<h3 className="legend">{t('Add Email to Blacklist')}</h3>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="email" label={t('Email')}/>
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Add to Blacklist')}/>
</ButtonRow>
</Form>
<hr/>
<h3 className="legend">{t('Blacklisted Emails')}</h3>
<Table ref={node => this.blacklistTable = node} withHeader dataUrl="/rest/blacklist-table" columns={columns} />
</div>
);
}
}

View file

@ -0,0 +1,34 @@
'use strict';
import React from "react";
import ReactDOM from "react-dom";
import {I18nextProvider} from "react-i18next";
import i18n from "../lib/i18n";
import {Section} from "../lib/page";
import List from "./List";
const getStructure = t => {
return {
'': {
title: t('Home'),
externalLink: '/',
children: {
'blacklist': {
title: t('Blacklist'),
link: '/blacklist',
panelComponent: List,
}
}
}
}
};
export default function() {
ReactDOM.render(
<I18nextProvider i18n={ i18n }><Section root='/blacklist' structure={getStructure}/></I18nextProvider>,
document.getElementById('root')
);
}

View file

@ -35,14 +35,19 @@ class DismissibleAlert extends Component {
class Icon extends Component {
static propTypes = {
icon: PropTypes.string.isRequired,
family: PropTypes.string,
title: PropTypes.string,
className: PropTypes.string
}
static defaultProps = {
family: 'glyphicon'
}
render() {
const props = this.props;
return <span className={'glyphicon glyphicon-' + props.icon + (props.className ? ' ' + props.className : '')} title={props.title}></span>;
return <span className={`${props.family} ${props.family}-${props.icon}` + (props.className ? ' ' + props.className : '')} title={props.title}></span>;
}
}
@ -129,6 +134,7 @@ class ActionLink extends Component {
async onClick(evt) {
if (this.props.onClickAsync) {
evt.preventDefault();
evt.stopPropagation();
await this.props.onClickAsync(evt);
}

View file

@ -10,13 +10,10 @@ import { withPageHelpers } from './page'
import { withErrorHandling, withAsyncErrorHandler } from './error-handling';
import { TreeTable, TreeSelectMode } from './tree';
import { Table, TableSelectMode } from './table';
import { Button } from "./bootstrap-components";
import {Button, Icon} from "./bootstrap-components";
import brace from 'brace';
import AceEditor from 'react-ace';
import 'brace/mode/javascript';
import 'brace/mode/json';
import 'brace/mode/handlebars';
import 'brace/theme/github';
import DayPicker from 'react-day-picker';
@ -42,7 +39,8 @@ class Form extends Component {
static propTypes = {
stateOwner: PropTypes.object.isRequired,
onSubmitAsync: PropTypes.func,
format: PropTypes.string
format: PropTypes.string,
noStatus: PropTypes.bool
}
static childContextTypes = {
@ -94,7 +92,7 @@ class Form extends Component {
<fieldset disabled={owner.isFormDisabled()}>
{props.children}
</fieldset>
{statusMessageText &&
{!props.noStatus && statusMessageText &&
<AlignedRow htmlId="form-status-message">
<p className={`alert alert-${statusMessageSeverity} ${styles.formStatus}`} role="alert">{statusMessageText}</p>
</AlignedRow>
@ -107,16 +105,44 @@ class Form extends Component {
class Fieldset extends Component {
static propTypes = {
label: PropTypes.string
id: PropTypes.string,
label: PropTypes.string,
help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
}
static contextTypes = {
formStateOwner: PropTypes.object.isRequired
}
render() {
const props = this.props;
const owner = this.context.formStateOwner;
const id = this.props.id;
const htmlId = 'form_' + id;
const className = id ? owner.addFormValidationClass('', id) : '';
let helpBlock = null;
if (this.props.help) {
helpBlock = <div className="help-block" id={htmlId + '_help'}>{this.props.help}</div>;
}
let validationBlock = null;
if (id) {
const validationMsg = id && owner.getFormValidationMessage(id);
if (validationMsg) {
validationBlock = <div className="help-block" id={htmlId + '_help_validation'}>{validationMsg}</div>;
}
}
return (
<fieldset>
<fieldset className={className}>
{props.label ? <legend>{props.label}</legend> : null}
<div className="fieldset-content">
{props.children}
{helpBlock}
{validationBlock}
</div>
</fieldset>
);
}
@ -411,6 +437,7 @@ class TextArea extends Component {
}
@translate()
class DatePicker extends Component {
constructor(props) {
super(props);
@ -426,7 +453,9 @@ class DatePicker extends Component {
help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
format: PropTypes.string,
birthday: PropTypes.bool,
dateFormat: PropTypes.string
dateFormat: PropTypes.string,
formatDate: PropTypes.func,
parseDate: PropTypes.func
}
static defaultProps = {
@ -447,7 +476,12 @@ class DatePicker extends Component {
const owner = this.context.formStateOwner;
const id = this.props.id;
const props = this.props;
if (props.formatDate) {
owner.updateFormValue(id, props.formatDate(date));
} else {
owner.updateFormValue(id, props.birthday ? formatBirthday(props.dateFormat, date) : formatDate(props.dateFormat, date));
}
this.setState({
opened: false
@ -459,6 +493,7 @@ class DatePicker extends Component {
const owner = this.context.formStateOwner;
const id = this.props.id;
const htmlId = 'form_' + id;
const t = props.t;
function BirthdayPickerCaption({ date, localeUtils, onChange }) {
const months = localeUtils.getMonths();
@ -472,7 +507,15 @@ class DatePicker extends Component {
let selectedDate, captionElement, fromMonth, toMonth, placeholder;
const selectedDateStr = owner.getFormValue(id) || '';
if (props.birthday) {
if (props.parseDate) {
selectedDate = props.parseDate(selectedDateStr);
if (selectedDate) {
selectedDate = moment(selectedDate).set('year', birthdayYear).toDate();
}
} else {
selectedDate = parseBirthday(props.dateFormat, selectedDateStr);
}
if (!selectedDate) {
selectedDate = moment().set('year', birthdayYear).toDate();
}
@ -482,8 +525,13 @@ class DatePicker extends Component {
toMonth = new Date(birthdayYear, 11, 31);
placeholder = getBirthdayFormatString(props.dateFormat);
} else {
if (props.parseDate) {
selectedDate = props.parseDate(selectedDateStr);
} else {
selectedDate = parseDate(props.dateFormat, selectedDateStr);
}
if (!selectedDate) {
selectedDate = moment().toDate();
}
@ -495,7 +543,7 @@ class DatePicker extends Component {
<div>
<div className="input-group">
<input type="text" value={selectedDateStr} placeholder={placeholder} id={htmlId} className="form-control" aria-describedby={htmlId + '_help'} onChange={evt => owner.updateFormValue(id, evt.target.value)}/>
<span className="input-group-addon" onClick={::this.toggleDayPicker}><span className="glyphicon glyphicon-th"></span></span>
<span className="input-group-addon" onClick={::this.toggleDayPicker}><Icon icon="calendar" title={t('Open calendar')}/></span>
</div>
{this.state.opened &&
<div className={styles.dayPickerWrapper}>
@ -729,7 +777,7 @@ class TableSelect extends Component {
</span>
</div>
<div className={styles.tableSelectTable + (this.state.open ? '' : ' ' + styles.tableSelectTableHidden)}>
<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}/>
<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} selectionKeyIndex={props.selectionKeyIndex} selection={owner.getFormValue(id)} onSelectionDataAsync={::this.onSelectionDataAsync} onSelectionChangedAsync={::this.onSelectionChangedAsync}/>
</div>
</div>
);
@ -737,7 +785,7 @@ class TableSelect extends Component {
return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
<div>
<div>
<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}/>
<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} selectionKeyIndex={props.selectionKeyIndex} selection={owner.getFormValue(id)} onSelectionChangedAsync={::this.onSelectionChangedAsync}/>
</div>
</div>
);
@ -938,6 +986,7 @@ function withForm(target) {
delete data.hash;
if (mutator) {
// FIXME - change the interface such that if the mutator is provided, it is supposed to return which fields to keep in the form
mutator(data);
}
@ -951,6 +1000,7 @@ function withForm(target) {
const data = this.getFormValues();
if (mutator) {
// FIXME - change the interface such that the mutator is supposed to create the object to be submitted
mutator(data);
}
@ -1152,6 +1202,17 @@ function withForm(target) {
return;
}
if (error instanceof interoperableErrors.NamespaceNotFoundError) {
this.disableForm();
this.setFormStatusMessage('danger',
<span>
<strong>{t('Your updates cannot be saved.')}</strong>{' '}
{t('It seems that someone else has deleted the target namespace in the meantime. Refresh your page to start anew with fresh data. Please note that your changes will be lost.')}
</span>
);
return;
}
if (error instanceof interoperableErrors.NotFoundError) {
this.disableForm();
this.setFormStatusMessage('danger',

View file

@ -0,0 +1,156 @@
'use strict';
import React from "react";
import PropTypes from "prop-types";
import {withRouter} from "react-router";
import {withErrorHandling} from "./error-handling";
import axios from "../lib/axios";
function needsResolve(route, nextRoute, match, nextMatch) {
const resolve = route.resolve;
const nextResolve = nextRoute.resolve;
if (Object.keys(resolve).length === Object.keys(nextResolve).length) {
for (const key in resolve) {
if (!(key in nextResolve) ||
resolve[key](match.params) !== nextResolve[key](nextMatch.params)) {
return true;
}
}
} else {
return true;
}
return false;
}
async function resolve(route, match) {
const keys = Object.keys(route.resolve);
const promises = keys.map(key => {
const url = route.resolve[key](match.params);
if (url) {
return axios.get(url);
} else {
return Promise.resolve({data: null});
}
});
const resolvedArr = await Promise.all(promises);
const resolved = {};
for (let idx = 0; idx < keys.length; idx++) {
resolved[keys[idx]] = resolvedArr[idx].data;
}
return resolved;
}
function getRoutes(urlPrefix, resolve, parents, structure, navs, primaryMenuComponent, secondaryMenuComponent) {
let routes = [];
for (let routeKey in structure) {
const entry = structure[routeKey];
let path = urlPrefix + routeKey;
let pathWithParams = path;
if (entry.extraParams) {
pathWithParams = pathWithParams + '/' + entry.extraParams.join('/');
}
let entryResolve;
if (entry.resolve) {
entryResolve = Object.assign({}, resolve, entry.resolve);
} else {
entryResolve = resolve;
}
let navKeys;
const entryNavs = [];
if (entry.navs) {
navKeys = Object.keys(entry.navs);
for (const navKey of navKeys) {
const nav = entry.navs[navKey];
entryNavs.push({
title: nav.title,
visible: nav.visible,
link: nav.link,
externalLink: nav.externalLink
});
}
}
const route = {
path: (pathWithParams === '' ? '/' : pathWithParams),
panelComponent: entry.panelComponent,
panelRender: entry.panelRender,
primaryMenuComponent: entry.primaryMenuComponent || primaryMenuComponent,
secondaryMenuComponent: entry.secondaryMenuComponent || secondaryMenuComponent,
title: entry.title,
link: entry.link,
resolve: entryResolve,
parents,
navs: [...navs, ...entryNavs]
};
routes.push(route);
const childrenParents = [...parents, route];
if (entry.navs) {
for (let navKeyIdx = 0; navKeyIdx < navKeys.length; navKeyIdx++) {
const navKey = navKeys[navKeyIdx];
const nav = entry.navs[navKey];
const childNavs = [...entryNavs];
childNavs[navKeyIdx] = Object.assign({}, childNavs[navKeyIdx], { active: true });
routes = routes.concat(getRoutes(path + '/', entryResolve, childrenParents, { [navKey]: nav }, childNavs, route.primaryMenuComponent, route.secondaryMenuComponent));
}
}
if (entry.children) {
routes = routes.concat(getRoutes(path + '/', entryResolve, childrenParents, entry.children, entryNavs, route.primaryMenuComponent, route.secondaryMenuComponent));
}
}
return routes;
}
function withPageHelpers(target) {
target = withErrorHandling(target);
const inst = target.prototype;
const contextTypes = target.contextTypes || {};
contextTypes.sectionContent = PropTypes.object.isRequired;
target.contextTypes = contextTypes;
inst.setFlashMessage = function(severity, text) {
return this.context.sectionContent.setFlashMessage(severity, text);
};
inst.navigateTo = function(path) {
return this.context.sectionContent.navigateTo(path);
}
inst.navigateBack = function() {
return this.context.sectionContent.navigateBack();
}
inst.navigateToWithFlashMessage = function(path, severity, text) {
return this.context.sectionContent.navigateToWithFlashMessage(path, severity, text);
}
return target;
}
export {
needsResolve,
resolve,
getRoutes,
withPageHelpers
};

View file

@ -1,17 +1,16 @@
'use strict';
import React, { Component } from 'react';
import { translate } from 'react-i18next';
import PropTypes from 'prop-types';
import { withRouter } from 'react-router';
import {BrowserRouter as Router, Route, Link, Switch, Redirect} from 'react-router-dom'
import { withErrorHandling, withAsyncErrorHandler } from './error-handling';
import interoperableErrors from '../../../shared/interoperable-errors';
import { DismissibleAlert, Button } from './bootstrap-components';
import mailtrainConfig from 'mailtrainConfig';
import axios from '../lib/axios';
import React, {Component} from "react";
import {translate} from "react-i18next";
import PropTypes from "prop-types";
import {withRouter} from "react-router";
import {BrowserRouter as Router, Link, Redirect, Route, Switch} from "react-router-dom";
import {withAsyncErrorHandler, withErrorHandling} from "./error-handling";
import interoperableErrors from "../../../shared/interoperable-errors";
import {Button, DismissibleAlert} from "./bootstrap-components";
import mailtrainConfig from "mailtrainConfig";
import styles from "./styles.scss";
import {getRoutes, needsResolve, resolve, withPageHelpers} from "./page-common";
class Breadcrumb extends Component {
static propTypes = {
@ -163,28 +162,42 @@ class RouteContent extends Component {
}
@withAsyncErrorHandler
async resolve() {
const route = this.props.route;
async resolve(props) {
if (Object.keys(props.route.resolve).length === 0) {
this.setState({
resolved: {}
});
const keys = Object.keys(route.resolve);
} else {
this.setState({
resolved: null
});
if (keys.length > 0) {
const promises = keys.map(key => axios.get(route.resolve[key](this.props.match.params)));
const resolvedArr = await Promise.all(promises);
const resolved = {};
for (let idx = 0; idx < keys.length; idx++) {
resolved[keys[idx]] = resolvedArr[idx].data;
}
const resolved = await resolve(props.route, props.match);
if (!this.disregardResolve) { // This is to prevent the warning about setState on discarded component when we immediatelly redirect.
this.setState({
resolved
});
}
}
}
componentDidMount() {
this.resolve();
this.resolve(this.props);
}
componentDidUpdate() {
}
componentWillReceiveProps(nextProps) {
if (this.props.match.params !== nextProps.match.params && needsResolve(this.props.route, nextProps.route, this.props.match, nextProps.match)) {
this.resolve(nextProps);
}
}
componentWillUnmount() {
this.disregardResolve = true; // This is to prevent the warning about setState on discarded component when we immediatelly redirect.
}
render() {
@ -193,7 +206,7 @@ class RouteContent extends Component {
const params = this.props.match.params;
const resolved = this.state.resolved;
if (!route.render && !route.component && route.link) {
if (!route.panelRender && !route.panelComponent && route.link) {
let link;
if (typeof route.link === 'function') {
link = route.link(params);
@ -211,11 +224,11 @@ class RouteContent extends Component {
resolved
};
let component;
if (route.render) {
component = route.render(compProps);
} else if (route.component) {
component = React.createElement(route.component, compProps, null);
let panel;
if (route.panelComponent) {
panel = React.createElement(route.panelComponent, compProps);
} else if (route.panelRender) {
panel = route.panelRender(compProps);
}
return (
@ -226,7 +239,7 @@ class RouteContent extends Component {
<SecondaryNavBar className="visible-xs" route={route} params={params} resolved={resolved}/>
</div>
{this.props.flashMessage}
{component}
{panel}
</div>
);
} else {
@ -291,14 +304,6 @@ class SectionContent extends Component {
};
}
getFlashMessageText() {
return this.state.flashMessageText;
}
getFlashMessageSeverity() {
return this.state.flashMessageSeverity;
}
setFlashMessage(severity, text) {
this.setState({
flashMessageText: text,
@ -346,77 +351,6 @@ class SectionContent extends Component {
})
}
getRoutes(urlPrefix, resolve, parents, structure, navs) {
let routes = [];
for (let routeKey in structure) {
const entry = structure[routeKey];
let path = urlPrefix + routeKey;
let pathWithParams = path;
if (entry.extraParams) {
pathWithParams = pathWithParams + '/' + entry.extraParams.join('/');
}
let entryResolve;
if (entry.resolve) {
entryResolve = Object.assign({}, resolve, entry.resolve);
} else {
entryResolve = resolve;
}
let navKeys;
const entryNavs = [];
if (entry.navs) {
navKeys = Object.keys(entry.navs);
for (const navKey of navKeys) {
const nav = entry.navs[navKey];
entryNavs.push({
title: nav.title,
visible: nav.visible,
link: nav.link,
externalLink: nav.externalLink
});
}
}
const route = {
path: (pathWithParams === '' ? '/' : pathWithParams),
component: entry.component,
render: entry.render,
title: entry.title,
link: entry.link,
resolve: entryResolve,
parents,
navs: [...navs, ...entryNavs]
};
routes.push(route);
const childrenParents = [...parents, route];
if (entry.navs) {
for (let navKeyIdx = 0; navKeyIdx < navKeys.length; navKeyIdx++) {
const navKey = navKeys[navKeyIdx];
const nav = entry.navs[navKey];
const childNavs = [...entryNavs];
childNavs[navKeyIdx] = Object.assign({}, childNavs[navKeyIdx], { active: true });
routes = routes.concat(this.getRoutes(path + '/', entryResolve, childrenParents, { [navKey]: nav }, childNavs));
}
}
if (entry.children) {
routes = routes.concat(this.getRoutes(path + '/', entryResolve, childrenParents, entry.children, entryNavs));
}
}
return routes;
}
renderRoute(route) {
let flashMessage;
if (this.state.flashMessageText) {
@ -429,7 +363,7 @@ class SectionContent extends Component {
}
render() {
let routes = this.getRoutes('', {}, [], this.props.structure, []);
let routes = getRoutes('', {}, [], this.props.structure, [], null, null);
return (
<Switch>{routes.map(x => this.renderRoute(x))}</Switch>
@ -526,44 +460,6 @@ class DropdownLink extends Component {
}
}
function withPageHelpers(target) {
target = withErrorHandling(target);
const inst = target.prototype;
const contextTypes = target.contextTypes || {};
contextTypes.sectionContent = PropTypes.object.isRequired;
target.contextTypes = contextTypes;
inst.getFlashMessageText = function() {
return this.context.sectionContent.getFlashMessageText();
};
inst.getFlashMessageSeverity = function() {
return this.context.sectionContent.getFlashMessageSeverity();
};
inst.setFlashMessage = function(severity, text) {
return this.context.sectionContent.setFlashMessage(severity, text);
};
inst.navigateTo = function(path) {
return this.context.sectionContent.navigateTo(path);
}
inst.navigateBack = function() {
return this.context.sectionContent.navigateBack();
}
inst.navigateToWithFlashMessage = function(path, severity, text) {
return this.context.sectionContent.navigateToWithFlashMessage(path, severity, text);
}
return target;
}
function requiresAuthenticatedUser(target) {
const comp1 = withPageHelpers(target);

View file

@ -102,6 +102,10 @@ class Table extends Component {
}
updateSelectInfo() {
if (!this.jqSelectInfo) {
return; // If the table is updated very quickly after mounting, the datatable may not be initialized yet.
}
const t = this.props.t;
const count = this.selectionMap.size;

View file

@ -13,6 +13,7 @@ import { DeleteModalDialog } from '../lib/modals';
import { validateNamespace, NamespaceSelect } from '../lib/namespace';
import { UnsubscriptionMode } from '../../../shared/lists';
import styles from "../lib/styles.scss";
import mailtrainConfig from 'mailtrainConfig';
@translate()
@withForm
@ -46,7 +47,7 @@ export default class CUD extends Component {
default_form: null,
public_subscribe: true,
unsubscription_mode: UnsubscriptionMode.ONE_STEP,
namespace: null
namespace: mailtrainConfig.user.namespace
});
}
}
@ -102,6 +103,7 @@ export default class CUD extends Component {
render() {
const t = this.props.t;
const isEdit = !!this.props.entity;
const canDelete = isEdit && this.props.entity.permissions.includes('delete');
const unsubcriptionModeOptions = [
{
@ -146,7 +148,7 @@ export default class CUD extends Component {
return (
<div>
{isEdit &&
{canDelete &&
<DeleteModalDialog
stateOwner={this}
visible={this.props.action === 'delete'}
@ -185,7 +187,7 @@ export default class CUD extends Component {
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>
{isEdit && <NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/lists/${this.props.entity.id}/delete`}/>}
{canDelete && <NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/lists/${this.props.entity.id}/delete`}/>}
</ButtonRow>
</Form>
</div>

View file

@ -16,6 +16,8 @@ import validators from '../../../../shared/validators';
import slugify from 'slugify';
import { parseDate, parseBirthday, DateFormat } from '../../../../shared/date';
import styles from "../../lib/styles.scss";
import 'brace/mode/json';
import 'brace/mode/handlebars';
@translate()
@withForm
@ -113,10 +115,7 @@ export default class CUD extends Component {
dateFormat: 'eur',
orderListBefore: 'end', // possible values are <numeric id> / 'end' / 'none'
orderSubscribeBefore: 'end',
orderManageBefore: 'end',
orderListOptions: [],
orderSubscribeOptions: [],
orderManageOptions: []
orderManageBefore: 'end'
});
}
}
@ -282,16 +281,6 @@ export default class CUD extends Component {
this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.'));
}
} catch (error) {
if (error instanceof interoperableErrors.DependencyNotFoundError) {
this.setFormStatusMessage('danger',
<span>
<strong>{t('Your updates cannot be saved.')}</strong>{' '}
{t('It seems that another field upon which sort field order was established has been deleted in the meantime. Refresh your page to start anew. Please note that your changes will be lost.')}
</span>
);
return;
}
throw error;
}
}
@ -459,7 +448,7 @@ export default class CUD extends Component {
{type !== 'option' &&
<Fieldset label={t('Field order')}>
<Dropdown id="orderListBefore" label={t('Listings (before)')} options={getOrderOptions('order_list')} help={t('Select the field before which this field should appeara in listings. To exclude the field from listings, select "Not visible".')}/>
<Dropdown id="orderListBefore" label={t('Listings (before)')} options={getOrderOptions('order_list')} help={t('Select the field before which this field should appear in listings. To exclude the field from listings, select "Not visible".')}/>
<Dropdown id="orderSubscribeBefore" label={t('Subscription form (before)')} options={getOrderOptions('order_subscribe')} help={t('Select the field before which this field should appear in new subscription form. To exclude the field from the new subscription form, select "Not visible".')}/>
<Dropdown id="orderManageBefore" label={t('Management form (before)')} options={getOrderOptions('order_manage')} help={t('Select the field before which this field should appear in subscription management. To exclude the field from the subscription management form, select "Not visible".')}/>
</Fieldset>

View file

@ -35,7 +35,7 @@ export default class List extends Component {
const columns = [
{ data: 4, title: "#" },
{ data: 1, title: t('Name'),
render: (data, cmd, rowData) => rowData[2] === 'option' ? <span><span className="glyphicon glyphicon-record" aria-hidden="true"></span> {data}</span> : data
render: (data, cmd, rowData) => rowData[2] === 'option' ? <span><Icon icon="record"/> {data}</span> : data
},
{ data: 2, title: t('Type'), render: data => this.fieldTypes[data].label, sortable: false, searchable: false },
{ data: 3, title: t('Merge Tag') },

View file

@ -262,7 +262,7 @@ export default class CUD extends Component {
name: '',
description: '',
selectedTemplate: 'layout',
namespace: null
namespace: mailtrainConfig.user.namespace
};
supplyDefaults(data);
@ -341,6 +341,7 @@ export default class CUD extends Component {
render() {
const t = this.props.t;
const isEdit = !!this.props.entity;
const canDelete = isEdit && this.props.entity.permissions.includes('delete');
const templateOptGroups = [];
@ -369,7 +370,7 @@ export default class CUD extends Component {
return (
<div>
{isEdit &&
{canDelete &&
<DeleteModalDialog
stateOwner={this}
visible={this.props.action === 'delete'}
@ -433,7 +434,7 @@ export default class CUD extends Component {
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>
{isEdit && <NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/lists/forms/${this.props.entity.id}/delete`}/>}
{canDelete && <NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/lists/forms/${this.props.entity.id}/delete`}/>}
</ButtonRow>
</Form>
</div>

View file

@ -21,8 +21,6 @@ import Share from '../shares/Share';
const getStructure = t => {
const subPaths = {};
return {
'': {
title: t('Home'),
@ -31,7 +29,7 @@ const getStructure = t => {
'lists': {
title: t('Lists'),
link: '/lists',
component: ListsList,
panelComponent: ListsList,
children: {
':listId([0-9]+)': {
title: resolved => t('List "{{name}}"', {name: resolved.list.name}),
@ -47,7 +45,7 @@ const getStructure = t => {
},
link: params => `/lists/${params.listId}/subscriptions`,
visible: resolved => resolved.list.permissions.includes('viewSubscriptions'),
render: props => <SubscriptionsList list={props.resolved.list} segments={props.resolved.segments} segmentId={qs.parse(props.location.search).segment} />,
panelRender: props => <SubscriptionsList list={props.resolved.list} segments={props.resolved.segments} segmentId={qs.parse(props.location.search).segment} />,
children: {
':subscriptionId([0-9]+)': {
title: resolved => resolved.subscription.email,
@ -60,7 +58,7 @@ const getStructure = t => {
':action(edit|delete)': {
title: t('Edit'),
link: params => `/lists/${params.listId}/subscriptions/${params.subscriptionId}/edit`,
render: props => <SubscriptionsCUD action={props.match.params.action} entity={props.resolved.subscription} list={props.resolved.list} fieldsGrouped={props.resolved.fieldsGrouped} />
panelRender: props => <SubscriptionsCUD action={props.match.params.action} entity={props.resolved.subscription} list={props.resolved.list} fieldsGrouped={props.resolved.fieldsGrouped} />
}
}
},
@ -69,20 +67,20 @@ const getStructure = t => {
resolve: {
fieldsGrouped: params => `/rest/fields-grouped/${params.listId}`
},
render: props => <SubscriptionsCUD action="create" list={props.resolved.list} fieldsGrouped={props.resolved.fieldsGrouped} />
panelRender: props => <SubscriptionsCUD action="create" list={props.resolved.list} fieldsGrouped={props.resolved.fieldsGrouped} />
}
} },
':action(edit|delete)': {
title: t('Edit'),
link: params => `/lists/${params.listId}/edit`,
visible: resolved => resolved.list.permissions.includes('edit'),
render: props => <ListsCUD action={props.match.params.action} entity={props.resolved.list} />
panelRender: props => <ListsCUD action={props.match.params.action} entity={props.resolved.list} />
},
fields: {
title: t('Fields'),
link: params => `/lists/${params.listId}/fields/`,
visible: resolved => resolved.list.permissions.includes('manageFields'),
render: props => <FieldsList list={props.resolved.list} />,
panelRender: props => <FieldsList list={props.resolved.list} />,
children: {
':fieldId([0-9]+)': {
title: resolved => t('Field "{{name}}"', {name: resolved.field.name}),
@ -95,7 +93,7 @@ const getStructure = t => {
':action(edit|delete)': {
title: t('Edit'),
link: params => `/lists/${params.listId}/fields/${params.fieldId}/edit`,
render: props => <FieldsCUD action={props.match.params.action} entity={props.resolved.field} list={props.resolved.list} fields={props.resolved.fields} />
panelRender: props => <FieldsCUD action={props.match.params.action} entity={props.resolved.field} list={props.resolved.list} fields={props.resolved.fields} />
}
}
},
@ -104,7 +102,7 @@ const getStructure = t => {
resolve: {
fields: params => `/rest/fields/${params.listId}`
},
render: props => <FieldsCUD action="create" list={props.resolved.list} fields={props.resolved.fields} />
panelRender: props => <FieldsCUD action="create" list={props.resolved.list} fields={props.resolved.fields} />
}
}
},
@ -112,7 +110,7 @@ const getStructure = t => {
title: t('Segments'),
link: params => `/lists/${params.listId}/segments`,
visible: resolved => resolved.list.permissions.includes('manageSegments'),
render: props => <SegmentsList list={props.resolved.list} />,
panelRender: props => <SegmentsList list={props.resolved.list} />,
children: {
':segmentId([0-9]+)': {
title: resolved => t('Segment "{{name}}"', {name: resolved.segment.name}),
@ -125,7 +123,7 @@ const getStructure = t => {
':action(edit|delete)': {
title: t('Edit'),
link: params => `/lists/${params.listId}/segments/${params.segmentId}/edit`,
render: props => <SegmentsCUD action={props.match.params.action} entity={props.resolved.segment} list={props.resolved.list} fields={props.resolved.fields} />
panelRender: props => <SegmentsCUD action={props.match.params.action} entity={props.resolved.segment} list={props.resolved.list} fields={props.resolved.fields} />
}
}
},
@ -134,7 +132,7 @@ const getStructure = t => {
resolve: {
fields: params => `/rest/fields/${params.listId}`
},
render: props => <SegmentsCUD action="create" list={props.resolved.list} fields={props.resolved.fields} />
panelRender: props => <SegmentsCUD action="create" list={props.resolved.list} fields={props.resolved.fields} />
}
}
},
@ -142,18 +140,18 @@ const getStructure = t => {
title: t('Share'),
link: params => `/lists/${params.listId}/share`,
visible: resolved => resolved.list.permissions.includes('share'),
render: props => <Share title={t('Share')} entity={props.resolved.list} entityTypeId="list" />
panelRender: props => <Share title={t('Share')} entity={props.resolved.list} entityTypeId="list" />
}
}
},
create: {
title: t('Create'),
render: props => <ListsCUD action="create" />
panelRender: props => <ListsCUD action="create" />
},
forms: {
title: t('Custom Forms'),
link: '/lists/forms',
component: FormsList,
panelComponent: FormsList,
children: {
':formsId([0-9]+)': {
title: resolved => t('Custom Forms "{{name}}"', {name: resolved.forms.name}),
@ -166,19 +164,19 @@ const getStructure = t => {
title: t('Edit'),
link: params => `/lists/forms/${params.formsId}/edit`,
visible: resolved => resolved.forms.permissions.includes('edit'),
render: props => <FormsCUD action={props.match.params.action} entity={props.resolved.forms} />
panelRender: props => <FormsCUD action={props.match.params.action} entity={props.resolved.forms} />
},
share: {
title: t('Share'),
link: params => `/lists/forms/${params.formsId}/share`,
visible: resolved => resolved.forms.permissions.includes('share'),
render: props => <Share title={t('Share')} entity={props.resolved.forms} entityTypeId="customForm" />
panelRender: props => <Share title={t('Share')} entity={props.resolved.forms} entityTypeId="customForm" />
}
}
},
create: {
title: t('Create'),
render: props => <FormsCUD action="create" />
panelRender: props => <FormsCUD action="create" />
}
}
}
@ -194,6 +192,6 @@ export default function() {
<I18nextProvider i18n={ i18n }><Section root='/lists' structure={getStructure}/></I18nextProvider>,
document.getElementById('root')
);
};
}

View file

@ -178,16 +178,6 @@ export default class CUD extends Component {
this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.'));
}
} catch (error) {
if (error instanceof interoperableErrors.DependencyNotFoundError) {
this.setFormStatusMessage('danger',
<span>
<strong>{t('Your updates cannot be saved.')}</strong>{' '}
{t('It seems that another field upon which sort field order was established has been deleted in the meantime. Refresh your page to start anew. Please note that your changes will be lost.')}
</span>
);
return;
}
throw error;
}
}

View file

@ -64,19 +64,19 @@ export default class List extends Component {
@withAsyncErrorHandler
async deleteSubscription(id) {
await axios.delete(`/rest/subscriptions/${this.props.list.id}/${id}`);
this.subscriptionsTable.refresh();
this.blacklistTable.refresh();
}
@withAsyncErrorHandler
async unsubscribeSubscription(id) {
await axios.post(`/rest/subscriptions-unsubscribe/${this.props.list.id}/${id}`);
this.subscriptionsTable.refresh();
this.blacklistTable.refresh();
}
@withAsyncErrorHandler
async blacklistSubscription(id) {
await axios.post(`/rest/XXX/${this.props.list.id}/${id}`); // FIXME - add url one the blacklist functionality is in
this.subscriptionsTable.refresh();
async blacklistSubscription(email) {
await axios.post("/rest/blacklist", { email });
this.blacklistTable.refresh();
}
render() {
@ -86,11 +86,11 @@ export default class List extends Component {
const columns = [
{ data: 2, title: t('Email') },
{ data: 3, title: t('Status'), render: data => this.subscriptionStatusLabels[data] },
{ data: 3, title: t('Status'), render: (data, display, rowData) => this.subscriptionStatusLabels[data] + (rowData[5] ? ', ' + t('Blacklisted') : '') },
{ data: 4, title: t('Created'), render: data => data ? moment(data).fromNow() : '' }
];
let colIdx = 5;
let colIdx = 6;
for (const fld of list.listFields) {
@ -123,11 +123,12 @@ export default class List extends Component {
});
}
// FIXME - add condition here to show it only if not blacklisted already
if (!data[5]) {
actions.push({
label: <Icon icon="ban-circle" title={t('Blacklist')}/>,
action: () => this.blacklistSubscription(data[0])
action: () => this.blacklistSubscription(data[2])
});
}
actions.push({
label: <Icon icon="remove" title={t('Remove')}/>,
@ -169,7 +170,7 @@ export default class List extends Component {
</div>
<Table ref={node => this.subscriptionsTable = node} withHeader dataUrl={dataUrl} columns={columns} />
<Table ref={node => this.blacklistTable = node} withHeader dataUrl={dataUrl} columns={columns} />
</div>
);
}

View file

@ -5,6 +5,7 @@ import {SubscriptionStatus} from "../../../../shared/lists";
import {ACEEditor, CheckBoxGroup, DatePicker, Dropdown, InputField, RadioGroup, TextArea} from "../../lib/form";
import {formatBirthday, formatDate, parseBirthday, parseDate} from "../../../../shared/date";
import {getFieldKey} from '../../../../shared/lists';
import 'brace/mode/json';
export function getSubscriptionStatusLabels(t) {

View file

@ -9,6 +9,7 @@ import axios from '../lib/axios';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import interoperableErrors from '../../../shared/interoperable-errors';
import {DeleteModalDialog} from "../lib/modals";
import mailtrainConfig from 'mailtrainConfig';
@translate()
@withForm
@ -34,10 +35,6 @@ export default class CUD extends Component {
return this.props.entity && this.props.entity.id === 1; /* Global namespace id */
}
isDelete() {
return this.props.match.params.action === 'delete';
}
removeNsIdSubtree(data) {
for (let idx = 0; idx < data.length; idx++) {
const entry = data[idx];
@ -84,7 +81,7 @@ export default class CUD extends Component {
this.populateFormValues({
name: '',
description: '',
namespace: null
namespace: mailtrainConfig.user.namespace
});
}
@ -179,10 +176,11 @@ export default class CUD extends Component {
render() {
const t = this.props.t;
const isEdit = !!this.props.entity;
const canDelete = isEdit && !this.isEditGlobal() && !this.hasChildren && this.props.entity.permissions.includes('delete');
return (
<div>
{!this.isEditGlobal() && !this.hasChildren && isEdit &&
{canDelete &&
<DeleteModalDialog
stateOwner={this}
visible={this.props.action === 'delete'}
@ -205,7 +203,7 @@ export default class CUD extends Component {
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>
{!this.isEditGlobal() && !this.hasChildren && isEdit && <NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/namespaces/${this.props.entity.id}/delete`}/>}
{canDelete && <NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/namespaces/${this.props.entity.id}/delete`}/>}
</ButtonRow>
</Form>
</div>

View file

@ -18,7 +18,7 @@ const getStructure = t => ({
namespaces: {
title: t('Namespaces'),
link: '/namespaces',
component: List,
panelComponent: List,
children: {
':namespaceId([0-9]+)': {
title: resolved => t('Namespace "{{name}}"', {name: resolved.namespace.name}),
@ -31,19 +31,19 @@ const getStructure = t => ({
title: t('Edit'),
link: params => `/namespaces/${params.namespaceId}/edit`,
visible: resolved => resolved.namespace.permissions.includes('edit'),
render: props => <CUD action={props.match.params.action} entity={props.resolved.namespace} />
panelRender: props => <CUD action={props.match.params.action} entity={props.resolved.namespace} />
},
share: {
title: t('Share'),
link: params => `/namespaces/${params.namespaceId}/share`,
visible: resolved => resolved.namespace.permissions.includes('share'),
render: props => <Share title={t('Share')} entity={props.resolved.namespace} entityTypeId="namespace" />
panelRender: props => <Share title={t('Share')} entity={props.resolved.namespace} entityTypeId="namespace" />
}
}
},
create: {
title: t('Create'),
render: props => <CUD action="create" />
panelRender: props => <CUD action="create" />
},
}
}
@ -56,6 +56,6 @@ export default function() {
<I18nextProvider i18n={ i18n }><Section root='/namespaces' structure={getStructure}/></I18nextProvider>,
document.getElementById('root')
);
};
}

View file

@ -13,6 +13,7 @@ import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling'
import moment from 'moment';
import { validateNamespace, NamespaceSelect } from '../lib/namespace';
import {DeleteModalDialog} from "../lib/modals";
import mailtrainConfig from 'mailtrainConfig';
@translate()
@withForm
@ -65,7 +66,7 @@ export default class CUD extends Component {
name: '',
description: '',
report_template: null,
namespace: null,
namespace: mailtrainConfig.user.namespace,
user_fields: null
});
}
@ -100,7 +101,7 @@ export default class CUD extends Component {
const selection = state.getIn([fldId, 'value']) || [];
if (spec.maxOccurences === 1) {
if (spec.minOccurences === 1 && (selection === null || selection === undefined)) {
if (spec.minOccurences === 1 && (selection === null || selection === undefined)) { // FIXME - this does not seem to correspond with selectionAsArray
state.setIn([fldId, 'error'], t('Exactly one item has to be selected'));
}
} else {
@ -119,7 +120,7 @@ export default class CUD extends Component {
async submitHandler() {
const t = this.props.t;
if (!this.getFormValue('user_fields')) {
if (this.getFormValue('report_template') && !this.getFormValue('user_fields')) {
this.setFormStatusMessage('warning', t('Report parameters are not selected. Wait for them to get displayed and then fill them in.'));
return;
}
@ -160,9 +161,9 @@ export default class CUD extends Component {
render() {
const t = this.props.t;
const isEdit = !!this.props.entity;
const canDelete = isEdit && this.props.entity.permissions.includes('delete');
const reportTemplateColumns = [
{ data: 0, title: "#" },
{ data: 1, title: t('Name') },
{ data: 2, title: t('Description') },
{ data: 3, title: t('Created'), render: data => moment(data).fromNow() }
@ -213,7 +214,7 @@ export default class CUD extends Component {
return (
<div>
{isEdit &&
{canDelete &&
<DeleteModalDialog
stateOwner={this}
visible={this.props.action === 'delete'}
@ -246,7 +247,9 @@ export default class CUD extends Component {
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>
{isEdit && <NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/reports/${this.props.entity.id}/delete`}/>}
{canDelete &&
<NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/reports/${this.props.entity.id}/delete`}/>
}
</ButtonRow>
</Form>
</div>

View file

@ -28,7 +28,7 @@ const getStructure = t => {
'reports': {
title: t('Reports'),
link: '/reports',
component: ReportsList,
panelComponent: ReportsList,
children: {
':reportId([0-9]+)': {
title: resolved => t('Report "{{name}}"', {name: resolved.report.name}),
@ -41,13 +41,13 @@ const getStructure = t => {
title: t('Edit'),
link: params => `/reports/${params.reportId}/edit`,
visible: resolved => resolved.report.permissions.includes('edit'),
render: props => <ReportsCUD action={props.match.params.action} entity={props.resolved.report} />
panelRender: props => <ReportsCUD action={props.match.params.action} entity={props.resolved.report} />
},
view: {
title: t('View'),
link: params => `/reports/${params.reportId}/view`,
visible: resolved => resolved.report.permissions.includes('viewContent') && resolved.report.state === ReportState.FINISHED && resolved.report.mime_type === 'text/html',
render: props => (<ReportsView {...props} />),
panelRender: props => (<ReportsView {...props} />),
},
download: {
title: t('Download'),
@ -58,24 +58,24 @@ const getStructure = t => {
title: t('Output'),
link: params => `/reports/${params.reportId}/output`,
visible: resolved => resolved.report.permissions.includes('viewOutput'),
render: props => (<ReportsOutput {...props} />)
panelRender: props => (<ReportsOutput {...props} />)
},
share: {
title: t('Share'),
link: params => `/reports/${params.reportId}/share`,
visible: resolved => resolved.report.permissions.includes('share'),
render: props => <Share title={t('Share')} entity={props.resolved.report} entityTypeId="report" />
panelRender: props => <Share title={t('Share')} entity={props.resolved.report} entityTypeId="report" />
}
}
},
create: {
title: t('Create'),
render: props => <ReportsCUD action="create" />
panelRender: props => <ReportsCUD action="create" />
},
'templates': {
title: t('Templates'),
link: '/reports/templates',
component: ReportTemplatesList,
panelComponent: ReportTemplatesList,
children: {
':templateId([0-9]+)': {
title: resolved => t('Template "{{name}}"', {name: resolved.template.name}),
@ -88,20 +88,20 @@ const getStructure = t => {
title: t('Edit'),
link: params => `/reports/templates/${params.templateId}/edit`,
visible: resolved => mailtrainConfig.globalPermissions.includes('createJavascriptWithROAccess') && resolved.template.permissions.includes('edit'),
render: props => <ReportTemplatesCUD action={props.match.params.action} entity={props.resolved.template} />
panelRender: props => <ReportTemplatesCUD action={props.match.params.action} entity={props.resolved.template} />
},
share: {
title: t('Share'),
link: params => `/reports/templates/${params.templateId}/share`,
visible: resolved => resolved.template.permissions.includes('share'),
render: props => <Share title={t('Share')} entity={props.resolved.template} entityTypeId="reportTemplate" />
panelRender: props => <Share title={t('Share')} entity={props.resolved.template} entityTypeId="reportTemplate" />
}
}
},
create: {
title: t('Create'),
extraParams: [':wizard?'],
render: props => <ReportTemplatesCUD action="create" wizard={props.match.params.wizard} />
panelRender: props => <ReportTemplatesCUD action="create" wizard={props.match.params.wizard} />
}
}
}

View file

@ -8,6 +8,10 @@ import { withForm, Form, FormSendMethod, InputField, TextArea, Dropdown, ACEEdit
import { withErrorHandling, withAsyncErrorHandler } from '../../lib/error-handling';
import { validateNamespace, NamespaceSelect } from '../../lib/namespace';
import {DeleteModalDialog} from "../../lib/modals";
import mailtrainConfig from 'mailtrainConfig';
import 'brace/mode/javascript';
import 'brace/mode/json';
import 'brace/mode/handlebars';
@translate()
@withForm
@ -45,7 +49,7 @@ export default class CUD extends Component {
this.populateFormValues({
name: '',
description: 'Generates a campaign report listing all subscribers along with their statistics.',
namespace: null,
namespace: mailtrainConfig.user.namespace,
mime_type: 'text/html',
user_fields:
'[\n' +
@ -95,7 +99,7 @@ export default class CUD extends Component {
this.populateFormValues({
name: '',
description: 'Generates a campaign report with results are aggregated by some "Country" custom field.',
namespace: null,
namespace: mailtrainConfig.user.namespace,
mime_type: 'text/html',
user_fields:
'[\n' +
@ -166,7 +170,7 @@ export default class CUD extends Component {
this.populateFormValues({
name: '',
description: 'Exports a list as a CSV file.',
namespace: null,
namespace: mailtrainConfig.user.namespace,
mime_type: 'text/csv',
user_fields:
'[\n' +
@ -191,7 +195,7 @@ export default class CUD extends Component {
this.populateFormValues({
name: '',
description: '',
namespace: null,
namespace: mailtrainConfig.user.namespace,
mime_type: 'text/html',
user_fields: '',
js: '',
@ -270,10 +274,11 @@ export default class CUD extends Component {
render() {
const t = this.props.t;
const isEdit = !!this.props.entity;
const canDelete = isEdit && this.props.entity.permissions.includes('delete');
return (
<div>
{isEdit &&
{canDelete &&
<DeleteModalDialog
stateOwner={this}
visible={this.props.action === 'delete'}
@ -299,7 +304,9 @@ export default class CUD extends Component {
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save and Stay')} onClickAsync={::this.submitAndStay}/>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save and Leave')}/>
{canDelete &&
<NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/reports/templates/${this.props.entity.id}/delete`}/>
}
</ButtonRow>
:
<ButtonRow>

View file

@ -52,7 +52,7 @@ export default class CUD extends Component {
email: '',
password: '',
password2: '',
namespace: null
namespace: mailtrainConfig.user.namespace
});
}
}
@ -182,7 +182,7 @@ export default class CUD extends Component {
const t = this.props.t;
const isEdit = !!this.props.entity;
const userId = this.getFormValue('id');
const canDelete = userId !== 1 && mailtrainConfig.userId !== userId;
const canDelete = isEdit && userId !== 1 && mailtrainConfig.user.id !== userId;
const rolesColumns = [
{ data: 1, title: "Name" },
@ -192,7 +192,7 @@ export default class CUD extends Component {
return (
<div>
{isEdit && canDelete &&
{canDelete &&
<DeleteModalDialog
stateOwner={this}
visible={this.props.action === 'delete'}
@ -220,7 +220,7 @@ export default class CUD extends Component {
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>
{isEdit && canDelete && <NavButton className="btn-danger" icon="remove" label={t('Delete User')} linkTo={`/users/${this.props.entity.id}/delete`}/>}
{canDelete && <NavButton className="btn-danger" icon="remove" label={t('Delete User')} linkTo={`/users/${this.props.entity.id}/delete`}/>}
</ButtonRow>
</Form>
</div>

View file

@ -1,10 +1,10 @@
'use strict';
import React, { Component } from 'react';
import { translate } from 'react-i18next';
import { requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, NavButton } from '../lib/page';
import { Table } from '../lib/table';
import mailtrainConfig from 'mailtrainConfig';
import React, {Component} from "react";
import {translate} from "react-i18next";
import {NavButton, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from "../lib/page";
import {Table} from "../lib/table";
import mailtrainConfig from "mailtrainConfig";
import {Icon} from "../lib/bootstrap-components";
@translate()

View file

@ -19,7 +19,7 @@ const getStructure = t => {
users: {
title: t('Users'),
link: '/users',
component: List,
panelComponent: List,
children: {
':userId([0-9]+)': {
title: resolved => t('User "{{name}}"', {name: resolved.user.name}),
@ -31,18 +31,18 @@ const getStructure = t => {
':action(edit|delete)': {
title: t('Edit'),
link: params => `/users/${params.userId}/edit`,
render: props => <CUD action={props.match.params.action} entity={props.resolved.user} />
panelRender: props => <CUD action={props.match.params.action} entity={props.resolved.user} />
},
shares: {
title: t('Shares'),
link: params => `/users/${params.userId}/shares`,
render: props => <UserShares user={props.resolved.user} />
panelRender: props => <UserShares user={props.resolved.user} />
}
}
},
create: {
title: t('Create'),
render: props => <CUD action="create" />
panelRender: props => <CUD action="create" />
},
}
}
@ -56,6 +56,6 @@ export default function() {
<I18nextProvider i18n={ i18n }><Section root='/users' structure={getStructure}/></I18nextProvider>,
document.getElementById('root')
);
};
}

View file

@ -7,7 +7,8 @@ module.exports = {
users: ['babel-polyfill', './src/users/root.js'],
account: ['babel-polyfill', './src/account/root.js'],
reports: ['babel-polyfill', './src/reports/root.js'],
lists: ['babel-polyfill', './src/lists/root.js']
lists: ['babel-polyfill', './src/lists/root.js'],
blacklist: ['babel-polyfill', './src/blacklist/root.js']
},
output: {
library: 'MailtrainReactBody',

View file

@ -196,7 +196,7 @@ browser="phantomjs"
name="Master"
admin=true
description="All permissions"
permissions=["rebuildPermissions", "createJavascriptWithROAccess"]
permissions=["rebuildPermissions", "createJavascriptWithROAccess", "manageBlacklist"]
rootNamespaceRole="master"
[roles.namespace.master]

View file

@ -19,7 +19,10 @@ async function getAnonymousConfig(context) {
async function getAuthenticatedConfig(context) {
return {
defaultCustomFormValues: await forms.getDefaultCustomFormValues(),
userId: context.user.id,
user: {
id: context.user.id,
namespace: context.user.namespace
},
globalPermissions: shares.getGlobalPermissions(context)
}
}

View file

@ -28,6 +28,7 @@ async function ajaxListTx(tx, params, queryFun, columns, options) {
const query = queryFun(tx);
query.whereIn(columnsNames[parseInt(params.column)], params.values);
query.select(columnsSelect);
query.options({rowsAsArray:true});
const rows = await query;
const rowsOfArray = rows.map(row => Object.keys(row).map(key => row[key]));

View file

@ -7,7 +7,7 @@ const interoperableErrors = require('../shared/interoperable-errors');
async function validateEntity(tx, entity) {
enforce(entity.namespace, 'Entity namespace not set');
if (!await tx('namespaces').where('id', entity.namespace).first()) {
throw new interoperableErrors.DependencyNotFoundError();
throw new interoperableErrors.NamespaceNotFoundError();
}
}

61
models/blacklist.js Normal file
View file

@ -0,0 +1,61 @@
'use strict';
const knex = require('../lib/knex');
const dtHelpers = require('../lib/dt-helpers');
const shares = require('./shares');
const tools = require('../lib/tools-async');
async function listDTAjax(context, params) {
shares.enforceGlobalPermission(context, 'manageBlacklist');
return await dtHelpers.ajaxList(
params,
builder => builder
.from('blacklist'),
['blacklist.email']
);
}
async function add(context, email) {
return await knex.transaction(async tx => {
shares.enforceGlobalPermission(context, 'manageBlacklist');
const existing = await tx('blacklist').where('email', email).first();
if (!existing) {
await tx('blacklist').insert({email});
}
});
}
async function remove(context, email) {
shares.enforceGlobalPermission(context, 'manageBlacklist');
await knex('blacklist').where('email', email).del();
}
async function isBlacklisted(email) {
const existing = await knex('blacklist').where('email', email).first();
return !!existing;
}
async function serverValidate(context, data) {
shares.enforceGlobalPermission(context, 'manageBlacklist');
const result = {};
if (data.email) {
const user = await knex('blacklist').where('email', data.email).first();
result.email = {};
result.email.invalid = await tools.validateEmail(data.email) !== 0;
result.email.exists = !!user;
}
return result;
}
module.exports = {
listDTAjax,
add,
remove,
isBlacklisted,
serverValidate
};

View file

@ -208,6 +208,8 @@ async function listDTAjax(context, listId, segmentId, params) {
return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'viewSubscriptions');
const listTable = getTableName(listId);
// All the data transformation below is to reuse ajaxListTx and groupSubscription methods so as to keep the code DRY
// We first construct the columns to contain all which is supposed to be show and extraColumns which contain
// everything else that constitutes the subscription.
@ -218,7 +220,14 @@ async function listDTAjax(context, listId, segmentId, params) {
const groupedFieldsMap = await getGroupedFieldsMap(tx, listId);
const listFlds = await fields.listByOrderListTx(tx, listId, ['column', 'id']);
const columns = ['id', 'cid', 'email', 'status', 'created'];
const columns = [
listTable + '.id',
listTable + '.cid',
listTable + '.email',
listTable + '.status',
listTable + '.created',
{ name: 'blacklisted', raw: 'not isnull(blacklist.email)' }
];
const extraColumns = [];
let listFldIdx = columns.length;
const idxMap = {};
@ -228,10 +237,10 @@ async function listDTAjax(context, listId, segmentId, params) {
const fld = groupedFieldsMap[fldKey];
if (fld.column) {
columns.push(fld.column);
columns.push(listTable + '.' + fld.column);
} else {
columns.push({
name: fldKey,
name: listTable + '.' + fldKey,
raw: 0
})
}
@ -245,14 +254,14 @@ async function listDTAjax(context, listId, segmentId, params) {
if (fld.column) {
if (!(fldKey in idxMap)) {
extraColumns.push(fld.column);
extraColumns.push(listTable + '.' + fld.column);
idxMap[fldKey] = listFldIdx;
listFldIdx += 1;
}
} else {
for (const optionColumn in fld.groupedOptions) {
extraColumns.push(optionColumn);
extraColumns.push(listTable + '.' + optionColumn);
idxMap[optionColumn] = listFldIdx;
listFldIdx += 1;
}
@ -265,7 +274,10 @@ async function listDTAjax(context, listId, segmentId, params) {
tx,
params,
builder => {
const query = builder.from(getTableName(listId));
const query = builder
.from(listTable)
.leftOuterJoin('blacklist', listTable + '.email', 'blacklist.email')
;
query.where(function() {
addSegmentQuery(this);
});

View file

@ -267,6 +267,8 @@ async function getByUsernameIfPasswordMatch(username, password) {
throw new interoperableErrors.IncorrectPasswordError();
}
delete user.password;
return user;
} catch (err) {

View file

@ -0,0 +1,10 @@
'use strict';
const _ = require('../lib/translate')._;
const clientHelpers = require('../lib/client-helpers');
const router = require('../lib/router-async').create();
clientHelpers.registerRootRoute(router, 'blacklist', _('Blacklist'));
module.exports = router;

View file

@ -1,68 +0,0 @@
'use strict';
let express = require('express');
let router = new express.Router();
let passport = require('../lib/passport');
let htmlescape = require('escape-html');
let blacklist = require('../lib/models/blacklist');
let tools = require('../lib/tools');
let helpers = require('../lib/helpers');
let _ = require('../lib/translate')._;
router.all('/*', (req, res, next) => {
if (!req.user) {
req.flash('danger', _('Need to be logged in to access restricted content'));
return res.redirect('/account/login?next=' + encodeURIComponent(req.originalUrl));
}
res.setSelectedMenu('blacklist');
next();
});
router.get('/', passport.csrfProtection, (req, res) => {
res.render('blacklist', {csrfToken: req.csrfToken()});
});
router.post('/ajax/', (req, res) => {
let start = parseInt(req.body.start || 0, 10);
let limit = parseInt(req.body.length || 50, 10);
let search = req.body.search.value || '';
blacklist.get(start, limit, search, (err, data, total) => {
if (err) {
req.flash('danger', err.message || err);
return res.redirect('/');
}
res.json({
draw: req.body.draw,
recordsTotal: total,
recordsFiltered: total,
data: data.map((row, i) => [
(Number(req.body.start) || 0) + 1 + i,
htmlescape(row),
'<button class="btn btn-danger btn-sm" onclick="document.getElementById(\'delete-email-input\').value = \'' + row + '\'; document.getElementById(\'delete-email-form\').submit();">Delete</button>'
])
});
});
});
router.post('/ajax/add', passport.csrfProtection, (req, res) => {
let email = req.body.email;
blacklist.add(email, (err) => {
if (err) {
req.flash('danger', err.message || err);
return res.redirect(req.body.next);
}
return res.redirect(req.body.next)
});
});
router.post('/ajax/delete', passport.csrfProtection, (req, res) => {
let email = req.body.email;
blacklist.delete(email, (err) => {
if (err) {
req.flash('danger', err.message || err);
return res.redirect(req.body.next);
}
return res.redirect(req.body.next);
});
});
module.exports = router;

View file

@ -18,7 +18,7 @@ router.postAsync('/account', passport.loggedIn, passport.csrfProtection, async (
const data = req.body;
data.id = req.user.id;
await users.updateWithConsistencyCheck(req.body, true);
await users.updateWithConsistencyCheck(contextHelpers.getAdminContext(), req.body, true);
return res.json();
});
@ -26,7 +26,7 @@ router.postAsync('/account-validate', passport.loggedIn, passport.csrfProtection
const data = req.body;
data.id = req.user.id;
return res.json(await users.serverValidate(req.context, data, true));
return res.json(await users.serverValidate(contextHelpers.getAdminContext(), data, true));
});
router.getAsync('/access-token', passport.loggedIn, async (req, res) => {

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

@ -0,0 +1,27 @@
'use strict';
const passport = require('../../lib/passport');
const blacklist = require('../../models/blacklist');
const router = require('../../lib/router-async').create();
router.postAsync('/blacklist-table', passport.loggedIn, async (req, res) => {
return res.json(await blacklist.listDTAjax(req.context, req.body));
});
router.postAsync('/blacklist', passport.loggedIn, passport.csrfProtection, async (req, res) => {
await blacklist.add(req.context, req.body.email);
return res.json();
});
router.deleteAsync('/blacklist/:email', passport.loggedIn, passport.csrfProtection, async (req, res) => {
await blacklist.remove(req.context, req.params.email);
return res.json();
});
router.postAsync('/blacklist-validate', passport.loggedIn, async (req, res) => {
return res.json(await blacklist.serverValidate(req.context, req.body));
});
module.exports = router;

View file

@ -40,9 +40,4 @@ router.getAsync('/report-template-user-fields/:reportTemplateId', passport.logge
return res.json(userFields);
});
router.getAsync('/report-templates-create-permitted', passport.loggedIn, async (req, res) => {
return res.json(await shares.checkTypePermission(req.context, 'namespace', 'createReportTemplate'));
});
module.exports = router;

View file

@ -70,7 +70,13 @@ class InvalidTokenError extends InteroperableError {
class DependencyNotFoundError extends InteroperableError {
constructor(msg, data) {
super('DependencyNotFound', msg, data);
super('DependencyNotFoundError', msg, data);
}
}
class NamespaceNotFoundError extends InteroperableError {
constructor(msg, data) {
super('NamespaceNotFoundError', msg, data);
}
}
@ -94,6 +100,7 @@ const errorTypes = {
IncorrectPasswordError,
InvalidTokenError,
DependencyNotFoundError,
NamespaceNotFoundError,
PermissionDeniedError
};