Editing of triggers seems to work.
Some further fixes.
This commit is contained in:
parent
ffc26a4836
commit
965f30cea7
23 changed files with 855 additions and 377 deletions
13
UPGRADE.md
Normal file
13
UPGRADE.md
Normal file
|
@ -0,0 +1,13 @@
|
|||
## Migration from Mailtrain v1 to Mailtrain v2
|
||||
|
||||
The migration should almost happen automatically. There are however the following caveats:
|
||||
|
||||
1. Structure of config files (under `config`) has changed at many places. Revisit the default config (`config/default.toml`)
|
||||
and update your configs accordingly.
|
||||
|
||||
2. Images uploaded in a template editor (Mosaico, GrapeJS, etc.) need to be manually moved to a new destination (under `client`).
|
||||
For Mosaico, this means to move folders named by a number from `public/mosaico` to `client/public/mosaico`.
|
||||
|
||||
3. Directory for custom Mosaico templates has changed from `public/mosaico/templates` to `client/public/mosaico/templates`.
|
||||
|
||||
4. Imports are not migrated. If you have any pending imports, complete them before migration to v2.
|
|
@ -217,34 +217,28 @@ export default class CUD extends Component {
|
|||
const t = this.props.t;
|
||||
const isEdit = !!this.props.entity;
|
||||
|
||||
for (const key of state.keys()) {
|
||||
state.setIn([key, 'error'], null);
|
||||
}
|
||||
|
||||
if (!state.getIn(['name', 'value'])) {
|
||||
state.setIn(['name', 'error'], t('Name must not be empty'));
|
||||
} else {
|
||||
state.setIn(['name', 'error'], null);
|
||||
}
|
||||
|
||||
if (!state.getIn(['list', 'value'])) {
|
||||
state.setIn(['list', 'error'], t('List must be selected'));
|
||||
} else {
|
||||
state.setIn(['list', 'error'], null);
|
||||
}
|
||||
|
||||
if (state.getIn(['useSegmentation', 'value']) && !state.getIn(['segment', 'value'])) {
|
||||
state.setIn(['segment', 'error'], t('Segment must be selected'));
|
||||
} else {
|
||||
state.setIn(['segment', 'error'], null);
|
||||
}
|
||||
|
||||
if (!state.getIn(['send_configuration', 'value'])) {
|
||||
state.setIn(['send_configuration', 'error'], t('Send configuration must be selected'));
|
||||
} else {
|
||||
state.setIn(['send_configuration', 'error'], null);
|
||||
}
|
||||
|
||||
if (state.getIn(['from_email_overriden', 'value']) && !state.getIn(['from_email_override', 'value'])) {
|
||||
state.setIn(['from_email_override', 'error'], t('"From" email must not be empty'));
|
||||
} else {
|
||||
state.setIn(['from_email_override', 'error'], null);
|
||||
}
|
||||
|
||||
|
||||
|
@ -252,12 +246,6 @@ export default class CUD extends Component {
|
|||
|
||||
const sourceTypeKey = Number.parseInt(state.getIn(['source', 'value']));
|
||||
|
||||
for (const key of state.keys()) {
|
||||
if (key.startsWith('data_')) {
|
||||
state.setIn([key, 'error'], null);
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceTypeKey === CampaignSource.TEMPLATE || (!isEdit && sourceTypeKey === CampaignSource.CUSTOM_FROM_TEMPLATE)) {
|
||||
if (!state.getIn(['data_sourceTemplate', 'value'])) {
|
||||
state.setIn(['data_sourceTemplate', 'error'], t('Template must be selected'));
|
||||
|
@ -292,7 +280,6 @@ export default class CUD extends Component {
|
|||
}
|
||||
|
||||
validateNamespace(t, state);
|
||||
|
||||
}
|
||||
|
||||
async submitHandler() {
|
||||
|
@ -361,8 +348,11 @@ export default class CUD extends Component {
|
|||
});
|
||||
|
||||
if (submitResponse) {
|
||||
const sourceTypeKey = Number.parseInt(this.getFormValue('source'));
|
||||
if (this.props.entity) {
|
||||
this.navigateToWithFlashMessage('/campaigns', 'success', t('Campaign saved'));
|
||||
} else if (sourceTypeKey === CampaignSource.CUSTOM || sourceTypeKey === CampaignSource.CUSTOM_FROM_TEMPLATE || sourceTypeKey === CampaignSource.CUSTOM_FROM_CAMPAIGN) {
|
||||
this.navigateToWithFlashMessage(`/campaigns/${submitResponse}/content`, 'success', t('Campaign saved'));
|
||||
} else {
|
||||
this.navigateToWithFlashMessage(`/campaigns/${submitResponse}/edit`, 'success', t('Campaign saved'));
|
||||
}
|
||||
|
@ -494,6 +484,14 @@ export default class CUD extends Component {
|
|||
templateEdit = <InputField id="data_sourceUrl" label={t('Render URL')} help={t('If a message is sent then this URL will be POSTed to using Merge Tags as POST body. Use this if you want to generate the HTML message yourself.')}/>
|
||||
}
|
||||
|
||||
let saveButtonLabel;
|
||||
if (isEdit) {
|
||||
saveButtonLabel = t('Save');
|
||||
} else if (sourceTypeKey === CampaignSource.CUSTOM || sourceTypeKey === CampaignSource.CUSTOM_FROM_TEMPLATE || sourceTypeKey === CampaignSource.CUSTOM_FROM_CAMPAIGN) {
|
||||
saveButtonLabel = t('Save and edit content');
|
||||
} else {
|
||||
saveButtonLabel = t('Save and edit campaign');
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
@ -545,7 +543,7 @@ export default class CUD extends Component {
|
|||
{templateEdit}
|
||||
|
||||
<ButtonRow>
|
||||
<Button type="submit" className="btn-primary" icon="ok" label={isEdit ? t('Save') : t('Save and edit campaign')}/>
|
||||
<Button type="submit" className="btn-primary" icon="ok" label={saveButtonLabel}/>
|
||||
{canDelete && <NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/campaigns/${this.props.entity.id}/delete`}/> }
|
||||
</ButtonRow>
|
||||
</Form>
|
||||
|
|
|
@ -97,6 +97,7 @@ export default class List extends Component {
|
|||
actions: data => {
|
||||
const actions = [];
|
||||
const perms = data[9];
|
||||
const campaignType = data[3];
|
||||
const campaignSource = data[6];
|
||||
|
||||
if (perms.includes('edit')) {
|
||||
|
@ -127,6 +128,13 @@ export default class List extends Component {
|
|||
});
|
||||
}
|
||||
|
||||
if (campaignType === CampaignType.TRIGGERED && perms.includes('viewTriggers')) {
|
||||
actions.push({
|
||||
label: <Icon icon="flash" title={t('Triggers')}/>,
|
||||
link: `/campaigns/${data[0]}/triggers`
|
||||
});
|
||||
}
|
||||
|
||||
if (perms.includes('share')) {
|
||||
actions.push({
|
||||
label: <Icon icon="share-alt" title={t('Share')}/>,
|
||||
|
|
|
@ -7,7 +7,12 @@ import Content from './Content';
|
|||
import CampaignsList from './List';
|
||||
import Share from '../shares/Share';
|
||||
import Files from "../lib/files";
|
||||
import {CampaignSource, CampaignType} from "../../../shared/campaigns";
|
||||
import {
|
||||
CampaignSource,
|
||||
CampaignType
|
||||
} from "../../../shared/campaigns";
|
||||
import TriggersCUD from './triggers/CUD';
|
||||
import TriggersList from './triggers/List';
|
||||
|
||||
|
||||
function getMenus(t) {
|
||||
|
@ -51,6 +56,32 @@ function getMenus(t) {
|
|||
visible: resolved => resolved.campaign.permissions.includes('viewAttachments'),
|
||||
panelRender: props => <Files title={t('Attachments')} help={t('These files will be attached to the campaign emails as proper attachments. This means they count towards to resulting eventual size of the email.')} entity={props.resolved.campaign} entityTypeId="campaign" entitySubTypeId="attachment" managePermission="manageAttachments"/>
|
||||
},
|
||||
triggers: {
|
||||
title: t('Triggers'),
|
||||
link: params => `/campaigns/${params.campaignId}/triggers/`,
|
||||
visible: resolved => resolved.campaign.type === CampaignType.TRIGGERED && resolved.campaign.permissions.includes('viewTriggers'),
|
||||
panelRender: props => <TriggersList campaign={props.resolved.campaign} />,
|
||||
children: {
|
||||
':triggerId([0-9]+)': {
|
||||
title: resolved => t('Trigger "{{name}}"', {name: resolved.trigger.name}),
|
||||
resolve: {
|
||||
trigger: params => `rest/triggers/${params.campaignId}/${params.triggerId}`,
|
||||
},
|
||||
link: params => `/campaigns/${params.campaignId}/triggers/${params.triggerId}/edit`,
|
||||
navs: {
|
||||
':action(edit|delete)': {
|
||||
title: t('Edit'),
|
||||
link: params => `/campaigns/${params.campaignId}/triggers/${params.triggerId}/edit`,
|
||||
panelRender: props => <TriggersCUD action={props.match.params.action} entity={props.resolved.trigger} campaign={props.resolved.campaign} />
|
||||
}
|
||||
}
|
||||
},
|
||||
create: {
|
||||
title: t('Create'),
|
||||
panelRender: props => <TriggersCUD action="create" campaign={props.resolved.campaign} />
|
||||
}
|
||||
}
|
||||
},
|
||||
share: {
|
||||
title: t('Share'),
|
||||
link: params => `/campaigns/${params.campaignId}/share`,
|
||||
|
|
237
client/src/campaigns/triggers/CUD.js
Normal file
237
client/src/campaigns/triggers/CUD.js
Normal file
|
@ -0,0 +1,237 @@
|
|||
'use strict';
|
||||
|
||||
import React, {Component} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {translate} from 'react-i18next';
|
||||
import {
|
||||
NavButton,
|
||||
requiresAuthenticatedUser,
|
||||
Title,
|
||||
withPageHelpers
|
||||
} from '../../lib/page';
|
||||
import {
|
||||
AlignedRow,
|
||||
Button,
|
||||
ButtonRow,
|
||||
CheckBox,
|
||||
Dropdown,
|
||||
Form,
|
||||
FormSendMethod,
|
||||
InputField,
|
||||
TableSelect,
|
||||
TextArea,
|
||||
withForm
|
||||
} from '../../lib/form';
|
||||
import {withErrorHandling} from '../../lib/error-handling';
|
||||
import {DeleteModalDialog} from "../../lib/modals";
|
||||
import {getTriggerTypes} from './helpers';
|
||||
import {
|
||||
Entity,
|
||||
Event
|
||||
} from '../../../../shared/triggers';
|
||||
import moment from 'moment';
|
||||
import {getCampaignTypeLabels} from "../helpers";
|
||||
|
||||
|
||||
@translate()
|
||||
@withForm
|
||||
@withPageHelpers
|
||||
@withErrorHandling
|
||||
@requiresAuthenticatedUser
|
||||
export default class CUD extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {};
|
||||
|
||||
this.campaignTypes = getCampaignTypeLabels(props.t);
|
||||
|
||||
const {entityLabels, eventLabels} = getTriggerTypes(props.t);
|
||||
this.entityLabels = entityLabels;
|
||||
|
||||
this.entityOptions = [
|
||||
{key: Entity.SUBSCRIPTION, label: entityLabels[Entity.SUBSCRIPTION]},
|
||||
{key: Entity.CAMPAIGN, label: entityLabels[Entity.CAMPAIGN]}
|
||||
];
|
||||
|
||||
const SubscriptionEvent = Event[Entity.SUBSCRIPTION];
|
||||
const CampaignEvent = Event[Entity.CAMPAIGN];
|
||||
this.eventOptions = {
|
||||
[Entity.SUBSCRIPTION]: [
|
||||
{key: SubscriptionEvent.CREATED, label: eventLabels[Entity.SUBSCRIPTION][SubscriptionEvent.CREATED]},
|
||||
{key: SubscriptionEvent.LATEST_OPEN, label: eventLabels[Entity.SUBSCRIPTION][SubscriptionEvent.LATEST_OPEN]},
|
||||
{key: SubscriptionEvent.LATEST_CLICK, label: eventLabels[Entity.SUBSCRIPTION][SubscriptionEvent.LATEST_CLICK]}
|
||||
],
|
||||
[Entity.CAMPAIGN]: [
|
||||
{key: CampaignEvent.DELIVERED, label: eventLabels[Entity.CAMPAIGN][CampaignEvent.DELIVERED]},
|
||||
{key: CampaignEvent.OPENED, label: eventLabels[Entity.CAMPAIGN][CampaignEvent.OPENED]},
|
||||
{key: CampaignEvent.CLICKED, label: eventLabels[Entity.CAMPAIGN][CampaignEvent.CLICKED]},
|
||||
{key: CampaignEvent.NOT_OPENED, label: eventLabels[Entity.CAMPAIGN][CampaignEvent.NOT_OPENED]},
|
||||
{key: CampaignEvent.NOT_CLICKED, label: eventLabels[Entity.CAMPAIGN][CampaignEvent.NOT_CLICKED]}
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
this.initForm();
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
action: PropTypes.string.isRequired,
|
||||
campaign: PropTypes.object,
|
||||
entity: PropTypes.object
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.entity) {
|
||||
this.getFormValuesFromEntity(this.props.entity, data => {
|
||||
data.daysAfter = (Math.round(data.seconds_after / (3600 * 24))).toString();
|
||||
|
||||
if (data.entity === Entity.SUBSCRIPTION) {
|
||||
data.subscriptionEvent = data.event;
|
||||
} else {
|
||||
data.subscriptionEvent = Event[Entity.SUBSCRIPTION].CREATED;
|
||||
}
|
||||
|
||||
if (data.entity === Entity.CAMPAIGN) {
|
||||
data.campaignEvent = data.event;
|
||||
} else {
|
||||
data.campaignEvent = Event[Entity.CAMPAIGN].DELIVERED;
|
||||
}
|
||||
});
|
||||
|
||||
} else {
|
||||
this.populateFormValues({
|
||||
name: '',
|
||||
description: '',
|
||||
entity: Entity.SUBSCRIPTION,
|
||||
subscriptionEvent: Event[Entity.SUBSCRIPTION].CREATED,
|
||||
campaignEvent: Event[Entity.CAMPAIGN].DELIVERED,
|
||||
daysAfter: '',
|
||||
enabled: true,
|
||||
source_campaign: null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
localValidateFormValues(state) {
|
||||
const t = this.props.t;
|
||||
|
||||
const entityKey = state.getIn(['entity', 'value']);
|
||||
|
||||
if (!state.getIn(['name', 'value'])) {
|
||||
state.setIn(['name', 'error'], t('Name must not be empty'));
|
||||
} else {
|
||||
state.setIn(['name', 'error'], null);
|
||||
}
|
||||
|
||||
const daysAfter = state.getIn(['daysAfter', 'value']).trim();
|
||||
if (daysAfter === '') {
|
||||
state.setIn(['daysAfter', 'error'], t('Values must not be empty'));
|
||||
} else if (isNaN(daysAfter) || Number.parseInt(daysAfter) < 0) {
|
||||
state.setIn(['daysAfter', 'error'], t('Value must be a non-negative number'));
|
||||
} else {
|
||||
state.setIn(['daysAfter', 'error'], null);
|
||||
}
|
||||
|
||||
if (entityKey === Entity.CAMPAIGN && !state.getIn(['source_campaign', 'value'])) {
|
||||
state.setIn(['source_campaign', 'error'], t('Source campaign must not be empty'));
|
||||
} else {
|
||||
state.setIn(['source_campaign', 'error'], null);
|
||||
}
|
||||
}
|
||||
|
||||
async submitHandler() {
|
||||
const t = this.props.t;
|
||||
|
||||
let sendMethod, url;
|
||||
if (this.props.entity) {
|
||||
sendMethod = FormSendMethod.PUT;
|
||||
url = `rest/triggers/${this.props.campaign.id}/${this.props.entity.id}`
|
||||
} else {
|
||||
sendMethod = FormSendMethod.POST;
|
||||
url = `rest/triggers/${this.props.campaign.id}`
|
||||
}
|
||||
|
||||
try {
|
||||
this.disableForm();
|
||||
this.setFormStatusMessage('info', t('Saving ...'));
|
||||
|
||||
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
|
||||
data.seconds_after = Number.parseInt(data.daysAfter) * 3600 * 24;
|
||||
|
||||
if (data.entity === Entity.SUBSCRIPTION) {
|
||||
data.event = data.subscriptionEvent;
|
||||
} else if (data.entity === Entity.CAMPAIGN) {
|
||||
data.event = data.campaignEvent;
|
||||
}
|
||||
});
|
||||
|
||||
if (submitSuccessful) {
|
||||
this.navigateToWithFlashMessage(`/campaigns/${this.props.campaign.id}/triggers`, 'success', t('Trigger saved'));
|
||||
} else {
|
||||
this.enableForm();
|
||||
this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.'));
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
const isEdit = !!this.props.entity;
|
||||
|
||||
const entityKey = this.getFormValue('entity');
|
||||
|
||||
const campaignsColumns = [
|
||||
{ data: 1, title: t('Name') },
|
||||
{ data: 2, title: t('Description') },
|
||||
{ data: 3, title: t('Type'), render: data => this.campaignTypes[data] },
|
||||
{ data: 4, title: t('Created'), render: data => moment(data).fromNow() },
|
||||
{ data: 5, title: t('Namespace') }
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isEdit &&
|
||||
<DeleteModalDialog
|
||||
stateOwner={this}
|
||||
visible={this.props.action === 'delete'}
|
||||
deleteUrl={`rest/triggers/${this.props.campaign.id}/${this.props.entity.id}`}
|
||||
cudUrl={`/campaigns/${this.props.campaign.id}/triggers/${this.props.entity.id}/edit`}
|
||||
listUrl={`/campaigns/${this.props.campaign.id}/triggers`}
|
||||
deletingMsg={t('Deleting trigger ...')}
|
||||
deletedMsg={t('Trigger deleted')}/>
|
||||
}
|
||||
|
||||
<Title>{isEdit ? t('Edit Trigger') : t('Create Trigger')}</Title>
|
||||
|
||||
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
|
||||
<InputField id="name" label={t('Name')}/>
|
||||
<TextArea id="description" label={t('Description')}/>
|
||||
|
||||
<Dropdown id="entity" label={t('Entity')} options={this.entityOptions} help={t('Select the type of the trigger rule.')}/>
|
||||
|
||||
<InputField id="daysAfter" label={t('Trigger fires')}/>
|
||||
|
||||
<AlignedRow>days after:</AlignedRow>
|
||||
|
||||
{entityKey === Entity.SUBSCRIPTION && <Dropdown id="subscriptionEvent" label={t('Event')} options={this.eventOptions[Entity.SUBSCRIPTION]} help={t('Select the event that triggers sending the campaign.')}/>}
|
||||
|
||||
{entityKey === Entity.CAMPAIGN && <Dropdown id="campaignEvent" label={t('Event')} options={this.eventOptions[Entity.CAMPAIGN]} help={t('Select the event that triggers sending the campaign.')}/>}
|
||||
|
||||
{entityKey === Entity.CAMPAIGN &&
|
||||
<TableSelect id="source_campaign" label={t('Campaign')} withHeader dropdown dataUrl={`rest/campaigns-others-by-list-table/${this.props.campaign.id}/${this.props.campaign.list}`} columns={campaignsColumns} selectionLabelIndex={1} />
|
||||
}
|
||||
|
||||
<CheckBox id="enabled" text={t('Enabled')}/>
|
||||
|
||||
<ButtonRow>
|
||||
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>
|
||||
{isEdit && <NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/campaigns/${this.props.campaign.id}/triggers/${this.props.entity.id}/delete`}/>}
|
||||
</ButtonRow>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
81
client/src/campaigns/triggers/List.js
Normal file
81
client/src/campaigns/triggers/List.js
Normal file
|
@ -0,0 +1,81 @@
|
|||
'use strict';
|
||||
|
||||
import React, {Component} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {translate} from 'react-i18next';
|
||||
import {
|
||||
NavButton,
|
||||
requiresAuthenticatedUser,
|
||||
Title,
|
||||
Toolbar,
|
||||
withPageHelpers
|
||||
} from '../../lib/page';
|
||||
import {withErrorHandling} from '../../lib/error-handling';
|
||||
import {Table} from '../../lib/table';
|
||||
import {getTriggerTypes} from './helpers';
|
||||
import {Icon} from "../../lib/bootstrap-components";
|
||||
|
||||
@translate()
|
||||
@withPageHelpers
|
||||
@withErrorHandling
|
||||
@requiresAuthenticatedUser
|
||||
export default class List extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const {entityLabels, eventLabels} = getTriggerTypes(props.t);
|
||||
this.entityLabels = entityLabels;
|
||||
this.eventLabels = eventLabels;
|
||||
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
campaign: PropTypes.object
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
}
|
||||
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
|
||||
const columns = [
|
||||
{ data: 1, title: t('Name') },
|
||||
{ data: 2, title: t('Description') },
|
||||
{ data: 3, title: t('List') },
|
||||
{ data: 4, title: t('Entity'), render: data => this.entityLabels[data], searchable: false },
|
||||
{ data: 5, title: t('Event'), render: (data, cmd, rowData) => this.eventLabels[rowData[4]][data], searchable: false },
|
||||
{ data: 6, title: t('Days after'), render: data => Math.round(data / (3600 * 24)) },
|
||||
{ data: 7, title: t('Enabled'), render: data => data ? t('Yes') : t('No'), searchable: false},
|
||||
{
|
||||
actions: data => {
|
||||
const actions = [];
|
||||
|
||||
if (this.props.campaign.permissions.includes('manageTriggers')) {
|
||||
actions.push({
|
||||
label: <Icon icon="edit" title={t('Edit')}/>,
|
||||
link: `/campaigns/${this.props.campaign.id}/triggers/${data[0]}/edit`
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{this.props.campaign.permissions.includes('manageTriggers') &&
|
||||
<Toolbar>
|
||||
<NavButton linkTo={`/campaigns/${this.props.campaign.id}/triggers/create`} className="btn-primary" icon="plus" label={t('Create Trigger')}/>
|
||||
</Toolbar>
|
||||
}
|
||||
|
||||
<Title>{t('Triggers')}</Title>
|
||||
|
||||
<Table withHeader dataUrl={`rest/triggers-by-campaign-table/${this.props.campaign.id}`} columns={columns} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
35
client/src/campaigns/triggers/helpers.js
Normal file
35
client/src/campaigns/triggers/helpers.js
Normal file
|
@ -0,0 +1,35 @@
|
|||
'use strict';
|
||||
|
||||
import {Entity, Event} from '../../../../shared/triggers';
|
||||
|
||||
export function getTriggerTypes(t) {
|
||||
|
||||
const entityLabels = {
|
||||
[Entity.SUBSCRIPTION]: t('Subscription'),
|
||||
[Entity.CAMPAIGN]: t('Campaign')
|
||||
};
|
||||
|
||||
const SubscriptionEvent = Event[Entity.SUBSCRIPTION];
|
||||
const CampaignEvent = Event[Entity.CAMPAIGN];
|
||||
|
||||
const eventLabels = {
|
||||
[Entity.SUBSCRIPTION]: {
|
||||
[SubscriptionEvent.CREATED]: t('Created'),
|
||||
[SubscriptionEvent.LATEST_OPEN]: t('Latest open'),
|
||||
[SubscriptionEvent.LATEST_CLICK]: t('Latest click')
|
||||
},
|
||||
[Entity.CAMPAIGN]: {
|
||||
[CampaignEvent.DELIVERED]: t('Delivered'),
|
||||
[CampaignEvent.OPENED]: t('Opened'),
|
||||
[CampaignEvent.CLICKED]: t('Clicked'),
|
||||
[CampaignEvent.NOT_OPENED]: t('Not opened'),
|
||||
[CampaignEvent.NOT_CLICKED]: t('Not clicked')
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
entityLabels,
|
||||
eventLabels
|
||||
};
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
'use strict';
|
||||
|
||||
import {anonymousRestrictedAccessToken} from '../../../shared/urls';
|
||||
import mailtrainConfig from "mailtrainConfig";
|
||||
|
||||
let restrictedAccessToken = 'ANONYMOUS';
|
||||
let restrictedAccessToken = anonymousRestrictedAccessToken;
|
||||
|
||||
function setRestrictedAccessToken(token) {
|
||||
restrictedAccessToken = token;
|
||||
|
@ -28,7 +29,7 @@ function getBaseDir() {
|
|||
if (mailtrainConfig.trusted) {
|
||||
return mailtrainConfig.trustedUrlBaseDir;
|
||||
} else {
|
||||
return mailtrainConfig.sandboxUrlBaseDir + 'ANONYMOUS';
|
||||
return mailtrainConfig.sandboxUrlBaseDir + anonymousRestrictedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -47,7 +47,7 @@ export default class List extends Component {
|
|||
data: 1,
|
||||
title: t('Name'),
|
||||
actions: data => {
|
||||
const perms = data[6];
|
||||
const perms = data[7];
|
||||
if (perms.includes('viewSubscriptions')) {
|
||||
return [{label: data[1], link: `/lists/${data[0]}/subscriptions`}];
|
||||
} else {
|
||||
|
@ -62,7 +62,9 @@ export default class List extends Component {
|
|||
{
|
||||
actions: data => {
|
||||
const actions = [];
|
||||
const perms = data[6];
|
||||
const triggersCount = data[6];
|
||||
const perms = data[7];
|
||||
console.log(data);
|
||||
|
||||
if (perms.includes('viewSubscriptions')) {
|
||||
actions.push({
|
||||
|
@ -92,6 +94,13 @@ export default class List extends Component {
|
|||
});
|
||||
}
|
||||
|
||||
if (triggersCount > 0) {
|
||||
actions.push({
|
||||
label: <Icon icon="flash" title={t('Triggers')}/>,
|
||||
link: `/lists/${data[0]}/triggers`
|
||||
});
|
||||
}
|
||||
|
||||
if (perms.includes('share')) {
|
||||
actions.push({
|
||||
label: <Icon icon="share-alt" title={t('Share')}/>,
|
||||
|
|
75
client/src/lists/TriggersList.js
Normal file
75
client/src/lists/TriggersList.js
Normal file
|
@ -0,0 +1,75 @@
|
|||
'use strict';
|
||||
|
||||
import React, {Component} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {translate} from 'react-i18next';
|
||||
import {
|
||||
requiresAuthenticatedUser,
|
||||
Title,
|
||||
withPageHelpers
|
||||
} from '../lib/page';
|
||||
import {withErrorHandling} from '../lib/error-handling';
|
||||
import {Table} from '../lib/table';
|
||||
import {getTriggerTypes} from '../campaigns/triggers/helpers';
|
||||
import {Icon} from "../lib/bootstrap-components";
|
||||
|
||||
@translate()
|
||||
@withPageHelpers
|
||||
@withErrorHandling
|
||||
@requiresAuthenticatedUser
|
||||
export default class List extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const {entityLabels, eventLabels} = getTriggerTypes(props.t);
|
||||
this.entityLabels = entityLabels;
|
||||
this.eventLabels = eventLabels;
|
||||
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
list: PropTypes.object
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
}
|
||||
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
|
||||
const columns = [
|
||||
{ data: 1, title: t('Name') },
|
||||
{ data: 2, title: t('Description') },
|
||||
{ data: 3, title: t('Campaign') },
|
||||
{ data: 4, title: t('Entity'), render: data => this.entityLabels[data], searchable: false },
|
||||
{ data: 5, title: t('Event'), render: (data, cmd, rowData) => this.eventLabels[rowData[4]][data], searchable: false },
|
||||
{ data: 6, title: t('Days after'), render: data => Math.round(data / (3600 * 24)) },
|
||||
{ data: 7, title: t('Enabled'), render: data => data ? t('Yes') : t('No'), searchable: false},
|
||||
{
|
||||
actions: data => {
|
||||
const actions = [];
|
||||
const perms = data[9];
|
||||
const campaignId = data[8];
|
||||
|
||||
if (perms.includes('manageTriggers')) {
|
||||
actions.push({
|
||||
label: <Icon icon="edit" title={t('Edit')}/>,
|
||||
link: `/campaigns/${campaignId}/triggers/${data[0]}/edit`
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title>{t('Triggers')}</Title>
|
||||
|
||||
<Table withHeader dataUrl={`rest/triggers-by-list-table/${this.props.list.id}`} columns={columns} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -13,7 +13,7 @@ import SubscriptionsCUD from './subscriptions/CUD';
|
|||
import SegmentsList from './segments/List';
|
||||
import SegmentsCUD from './segments/CUD';
|
||||
import Share from '../shares/Share';
|
||||
|
||||
import TriggersList from './TriggersList';
|
||||
|
||||
function getMenus(t) {
|
||||
return {
|
||||
|
@ -127,6 +127,11 @@ function getMenus(t) {
|
|||
}
|
||||
}
|
||||
},
|
||||
triggers: {
|
||||
title: t('Triggers'),
|
||||
link: params => `/lists/${params.listId}/triggers`,
|
||||
panelRender: props => <TriggersList list={props.resolved.list} />
|
||||
},
|
||||
share: {
|
||||
title: t('Share'),
|
||||
link: params => `/lists/${params.listId}/share`,
|
||||
|
|
|
@ -94,17 +94,17 @@ export default class CUD extends Component {
|
|||
localValidateFormValues(state) {
|
||||
const t = this.props.t;
|
||||
|
||||
for (const key of state.keys()) {
|
||||
state.setIn([key, 'error'], null);
|
||||
}
|
||||
|
||||
if (!state.getIn(['name', 'value'])) {
|
||||
state.setIn(['name', 'error'], t('Name must not be empty'));
|
||||
} else {
|
||||
state.setIn(['name', 'error'], null);
|
||||
}
|
||||
|
||||
const typeKey = state.getIn(['type', 'value']);
|
||||
if (!typeKey) {
|
||||
state.setIn(['type', 'error'], t('Type must be selected'));
|
||||
} else {
|
||||
state.setIn(['type', 'error'], null);
|
||||
}
|
||||
|
||||
validateNamespace(t, state);
|
||||
|
|
|
@ -112,14 +112,12 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
const mosaicoTemplate = state.getIn([prefix + 'mosaicoTemplate', 'value']);
|
||||
if (!mosaicoTemplate) {
|
||||
state.setIn([prefix + 'mosaicoTemplate', 'error'], t('Mosaico template must be selected'));
|
||||
} else {
|
||||
state.setIn([prefix + 'mosaicoTemplate', 'error'], null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const mosaicoFsTemplatesOptions = mailtrainConfig.mosaico.fsTemplates.map(([key, label]) => ({key, label}));
|
||||
const mosaicoFsTemplatesLabels = new Map(mailtrainConfig.mosaico.fsTemplates);
|
||||
const mosaicoFsTemplatesOptions = mailtrainConfig.mosaico.fsTemplates;
|
||||
const mosaicoFsTemplatesLabels = new Map(mailtrainConfig.mosaico.fsTemplates.map(({key, label}) => ([key, label])));
|
||||
|
||||
templateTypes.mosaicoWithFsTemplate = {
|
||||
typeName: t('Mosaico with predefined templates'),
|
||||
|
@ -151,7 +149,7 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
});
|
||||
},
|
||||
initData: () => ({
|
||||
[prefix + 'mosaicoFsTemplate']: mailtrainConfig.mosaico.fsTemplates[0][0],
|
||||
[prefix + 'mosaicoFsTemplate']: mailtrainConfig.mosaico.fsTemplates[0].key,
|
||||
[prefix + 'mosaicoData']: {}
|
||||
}),
|
||||
afterLoad: data => {
|
||||
|
|
|
@ -1,322 +0,0 @@
|
|||
# This file is the default config file for Mailtrain. To use a environment specific
|
||||
# configuration add new file {ENV}.{ext} (eg. "production.toml") to the same folder.
|
||||
# {ENV} is defined by NODE_ENV environment variable.
|
||||
#
|
||||
# Do not modify this file directly, otherwise you might lose your modifications when upgrading
|
||||
#
|
||||
# You should only define the options you want to change in your additional config file.
|
||||
# For example if the only thing you want to change is the port number for the www server
|
||||
# then your additional config file should look like this:
|
||||
# # production.toml
|
||||
# [www]
|
||||
# port=80
|
||||
# or if you want to use Javascript instead of TOML then the same file could look like this:
|
||||
# // production.js
|
||||
# module.exports = {
|
||||
# www: {
|
||||
# port: 80
|
||||
# }
|
||||
# };
|
||||
|
||||
# Process title visible in monitoring logs and process listing
|
||||
title="mailtrain"
|
||||
|
||||
# Enabled HTML editors
|
||||
editors=["ckeditor", "codeeditor", "mosaico", "mosaicoWithFsTemplate"]
|
||||
|
||||
# Default language to use
|
||||
language="en"
|
||||
|
||||
# Inject custom scripts in subscription/layout.mjml.hbs
|
||||
# customSubscriptionScripts=["/custom/hello-world.js"]
|
||||
|
||||
# If you start out as a root user (eg. if you want to use ports lower than 1000)
|
||||
# then you can downgrade the user once all services are up and running
|
||||
#user="mailtrain"
|
||||
#group="mailtrain"
|
||||
|
||||
# If Mailtrain is started as root, "Reports" feature drops the privileges of script generating the report to disallow
|
||||
# any modifications of Mailtrain code and even prohibits reading the production configuration (which contains the MySQL
|
||||
# password for read/write operations). The roUser/roGroup determines the user to be used
|
||||
#roUser="nobody"
|
||||
#roGroup="nogroup"
|
||||
|
||||
[log]
|
||||
# silly|verbose|info|http|warn|error|silent
|
||||
level="verbose"
|
||||
|
||||
[www]
|
||||
# HTTP port to listen on
|
||||
port=3000
|
||||
# HTTP port to listen on for sandboxed requests
|
||||
sandboxPort=8081
|
||||
# HTTP interface to listen on
|
||||
host="0.0.0.0"
|
||||
# URL base for trusted urls. It must be absolute (starting with http:// or https://). If Mailtrain is served on
|
||||
# a non-standard port (e.g. 3000), the URL must also specify the port.
|
||||
trustedUrlBase="http://localhost:3000"
|
||||
# URL base for sandbox urls. It must be absolute (starting with http:// or https://) and contain the sandbox port.
|
||||
sandboxUrlBase="http://localhost:8081"
|
||||
|
||||
# Secret for signing the session ID cookie
|
||||
secret="a cat"
|
||||
# Session length in seconds when "remember me" is checked
|
||||
remember=2592000 # 30 days
|
||||
# logger interface for expressjs morgan
|
||||
log="dev"
|
||||
# Is the server behind a proxy? true/false
|
||||
# Set this to true if you are serving Mailtrain as a virtual domain through Nginx or Apache
|
||||
proxy=false
|
||||
# maximum POST body size
|
||||
postSize="2MB"
|
||||
|
||||
[mysql]
|
||||
host="localhost"
|
||||
user="mailtrain"
|
||||
password="mailtrain"
|
||||
database="mailtrain"
|
||||
# Some installations, eg. MAMP can use a different port (8889)
|
||||
# MAMP users should also turn on "Allow network access to MySQL" otherwise MySQL might not be accessible
|
||||
port=3306
|
||||
charset="utf8mb4"
|
||||
# The timezone configured on the MySQL server. This can be 'local', 'Z', or an offset in the form +HH:MM or -HH:MM
|
||||
timezone="local"
|
||||
|
||||
[redis]
|
||||
# enable to use Redis session cache or disable if Redis is not installed
|
||||
enabled=false
|
||||
host="localhost"
|
||||
port=6379
|
||||
db=5
|
||||
# Uncomment if your Redis installation requires a password
|
||||
#password=""
|
||||
|
||||
[verp]
|
||||
# Enable to start an MX server that detects bounced messages using VERP
|
||||
# In most cases you do not want to use it
|
||||
# Requires root privileges
|
||||
enabled=false
|
||||
port=2525
|
||||
host="0.0.0.0"
|
||||
# With DMARC, the Return-Path and From address must match the same domain.
|
||||
# By default we get around this by using the VERP address in the Sender header,
|
||||
# with the side effect that some email clients diplay an ugly "on behalf of" message.
|
||||
# You can safely disable this Sender header if you're not using DMARC or your
|
||||
# VERP hostname is in the same domain as the From address.
|
||||
# disablesenderheader=true
|
||||
|
||||
[ldap]
|
||||
# enable to use ldap user backend
|
||||
enabled=false
|
||||
# method is ldapjs or ldapauth - it chooses the library to be used. If not given, it chooses the one present.
|
||||
# method="ldapjs"
|
||||
host="localhost"
|
||||
port=3002
|
||||
baseDN="ou=users,dc=company"
|
||||
filter="(|(username={{username}})(mail={{username}}))"
|
||||
# Username field in LDAP (uid/cn/username)
|
||||
uidTag="username"
|
||||
# nameTag identifies the attribute to be used for user's full name
|
||||
nameTag="username"
|
||||
passwordresetlink=""
|
||||
newUserRole="master"
|
||||
# Global namespace id
|
||||
newUserNamespaceId=1
|
||||
# Use a different user to bind LDAP (final bind DN will be: {{uidTag}}={{bindUser}},{{baseDN}})
|
||||
bindUser="name@company.net"
|
||||
bindPassword="mySecretPassword"
|
||||
|
||||
[postfixbounce]
|
||||
# Enable to allow writing Postfix bounce log to Mailtrain listener
|
||||
# If enabled, tail mail.log to Mailtrain with the following command:
|
||||
# tail -f -n +0 /var/log/mail.log | nc localhost 5699 -
|
||||
enabled=false
|
||||
port=5699
|
||||
# allow connections from localhost only
|
||||
host="127.0.0.1"
|
||||
|
||||
# extra options for nodemailer
|
||||
[nodemailer]
|
||||
#textEncoding="base64"
|
||||
|
||||
[queue]
|
||||
# How many parallel sender processes to spawn
|
||||
# You can use more than 1 process only if you have Redis enabled
|
||||
processes=1
|
||||
|
||||
[cors]
|
||||
# Allow subscription widgets to be embedded
|
||||
# origins=['https://www.example.com']
|
||||
|
||||
[mosaico]
|
||||
# Installed templates
|
||||
fsTemplates=[["versafix-1", "Versafix One"]]
|
||||
# Inject custom scripts
|
||||
# customscripts=["/mosaico/custom/my-mosaico-plugin.js"]
|
||||
|
||||
[grapejs]
|
||||
# Installed templates
|
||||
templates=[
|
||||
["demo", "HTML Template"],
|
||||
["aves", "MJML Template"]
|
||||
]
|
||||
|
||||
[reports]
|
||||
# The whole reporting functionality can be disabled below if the they are not needed and the DB cannot be
|
||||
# properly protected.
|
||||
# Reports rely on custom user defined Javascript snippets defined in the report template. The snippets are run on the
|
||||
# server when generating a report. As these snippets are stored in the DB, they pose a security risk because they can
|
||||
# help gaining access to the server if the DB cannot
|
||||
# be properly protected (e.g. if it is shared with another application with security weaknesses).
|
||||
# Mailtrain mitigates this problem by running the custom Javascript snippets in a chrooted environment and under a
|
||||
# DB user that cannot modify the database (see userRO in [mysql] above). However the chrooted environment is available
|
||||
# only if Mailtrain is started as root. The chrooted environment still does not prevent the custom JS script in
|
||||
# performing network operations and in generating XSS attacks as part of the report.
|
||||
# The bottom line is that if people who are creating report templates or have write access to the DB cannot be trusted,
|
||||
# then it's safer to switch off the reporting functionality below.
|
||||
enabled=false
|
||||
|
||||
[testserver]
|
||||
# Starts a vanity server that redirects all mail to /dev/null
|
||||
# Mostly needed for local development
|
||||
enabled=false
|
||||
port=5587
|
||||
mailboxserverport=3001
|
||||
host="0.0.0.0"
|
||||
username="testuser"
|
||||
password="testpass"
|
||||
logger=false
|
||||
|
||||
[seleniumwebdriver]
|
||||
browser="phantomjs"
|
||||
|
||||
|
||||
|
||||
[roles.global.master]
|
||||
name="Master"
|
||||
admin=true
|
||||
description="All permissions"
|
||||
permissions=["rebuildPermissions", "createJavascriptWithROAccess", "manageBlacklist", "manageSettings"]
|
||||
rootNamespaceRole="master"
|
||||
|
||||
[roles.namespace.master]
|
||||
name="Master"
|
||||
description="All permissions"
|
||||
permissions=["view", "edit", "delete", "share", "createNamespace", "createList", "createCustomForm", "createReport", "createReportTemplate", "createTemplate", "createMosaicoTemplate", "createSendConfiguration", "createCampaign", "manageUsers"]
|
||||
|
||||
[roles.namespace.master.children]
|
||||
sendConfiguration=["viewPublic", "viewPrivate", "edit", "delete", "share", "sendWithoutOverrides", "sendWithAllowedOverrides", "sendWithAnyOverrides"]
|
||||
list=["view", "edit", "delete", "share", "viewFields", "manageFields", "viewSubscriptions", "manageSubscriptions", "viewSegments", "manageSegments"]
|
||||
customForm=["view", "edit", "delete", "share"]
|
||||
campaign=["view", "edit", "delete", "share", "viewFiles", "manageFiles", "viewAttachments", "manageAttachments", "viewTriggers", "manageTriggers", "send", "viewStats"]
|
||||
template=["view", "edit", "delete", "share", "viewFiles", "manageFiles"]
|
||||
report=["view", "edit", "delete", "share", "execute", "viewContent", "viewOutput"]
|
||||
reportTemplate=["view", "edit", "delete", "share", "execute"]
|
||||
mosaicoTemplate=["view", "edit", "delete", "share", "viewFiles", "manageFiles"]
|
||||
namespace=["view", "edit", "delete", "share", "createNamespace", "createList", "createCustomForm", "createReport", "createReportTemplate", "createTemplate", "createMosaicoTemplate", "createSendConfiguration", "createCampaign", "manageUsers"]
|
||||
|
||||
[roles.sendConfiguration.master]
|
||||
name="Master"
|
||||
description="All permissions"
|
||||
permissions=["viewPublic", "viewPrivate", "edit", "delete", "share", "sendWithoutOverrides", "sendWithAllowedOverrides", "sendWithAnyOverrides"]
|
||||
|
||||
[roles.list.master]
|
||||
name="Master"
|
||||
description="All permissions"
|
||||
permissions=["view", "edit", "delete", "share", "viewFields", "manageFields", "viewSubscriptions", "manageSubscriptions", "viewSegments", "manageSegments"]
|
||||
|
||||
[roles.customForm.master]
|
||||
name="Master"
|
||||
description="All permissions"
|
||||
permissions=["view", "edit", "delete", "share"]
|
||||
|
||||
[roles.campaign.master]
|
||||
name="Master"
|
||||
description="All permissions"
|
||||
permissions=["view", "edit", "delete", "share", "viewFiles", "manageFiles", "viewAttachments", "manageAttachments", "viewTriggers", "manageTriggers", "send", "viewStats"]
|
||||
|
||||
[roles.template.master]
|
||||
name="Master"
|
||||
description="All permissions"
|
||||
permissions=["view", "edit", "delete", "share", "viewFiles", "manageFiles"]
|
||||
|
||||
[roles.report.master]
|
||||
name="Master"
|
||||
description="All permissions"
|
||||
permissions=["view", "edit", "delete", "share", "execute", "viewContent", "viewOutput"]
|
||||
|
||||
[roles.reportTemplate.master]
|
||||
name="Master"
|
||||
description="All permissions"
|
||||
permissions=["view", "edit", "delete", "share", "execute"]
|
||||
|
||||
[roles.mosaicoTemplate.master]
|
||||
name="Master"
|
||||
description="All permissions"
|
||||
permissions=["view", "edit", "delete", "share", "viewFiles", "manageFiles"]
|
||||
|
||||
|
||||
|
||||
|
||||
[roles.global.editor]
|
||||
name="Editor"
|
||||
description="XXX"
|
||||
permissions=[]
|
||||
ownNamespaceRole="editor"
|
||||
|
||||
[roles.namespace.editor]
|
||||
name="Editor"
|
||||
description="XXX"
|
||||
permissions=["view", "edit", "delete"]
|
||||
|
||||
[roles.namespace.editor.children]
|
||||
sendConfiguration=[]
|
||||
list=[]
|
||||
customForm=[]
|
||||
campaign=[]
|
||||
template=[]
|
||||
report=[]
|
||||
reportTemplate=[]
|
||||
namespace=["view", "edit", "delete"]
|
||||
mosaicoTemplate=[]
|
||||
|
||||
[roles.sendConfiguration.editor]
|
||||
name="Editor"
|
||||
description="XXX"
|
||||
permissions=[]
|
||||
|
||||
[roles.list.editor]
|
||||
name="Editor"
|
||||
description="XXX"
|
||||
permissions=[]
|
||||
|
||||
[roles.customForm.editor]
|
||||
name="Editor"
|
||||
description="All permissions"
|
||||
permissions=[]
|
||||
|
||||
[roles.campaign.editor]
|
||||
name="Editor"
|
||||
description="XXX"
|
||||
permissions=[]
|
||||
|
||||
[roles.template.editor]
|
||||
name="Editor"
|
||||
description="XXX"
|
||||
permissions=[]
|
||||
|
||||
[roles.report.editor]
|
||||
name="Editor"
|
||||
description="XXX"
|
||||
permissions=[]
|
||||
|
||||
[roles.reportTemplate.editor]
|
||||
name="Editor"
|
||||
description="XXX"
|
||||
permissions=[]
|
||||
|
||||
[roles.mosaicoTemplate.editor]
|
||||
name="Editor"
|
||||
description="XXX"
|
||||
permissions=[]
|
||||
|
268
config/default.yaml
Normal file
268
config/default.yaml
Normal file
|
@ -0,0 +1,268 @@
|
|||
# This file is the default config file for Mailtrain. To use a environment specific
|
||||
# configuration add new file {ENV}.{ext} (eg. production.yaml) to the same folder.
|
||||
# {ENV} is defined by NODE_ENV environment variable.
|
||||
#
|
||||
# Do not modify this file directly, otherwise you might lose your modifications when upgrading
|
||||
#
|
||||
# You should only define the options you want to change in your additional config file.
|
||||
# For example if the only thing you want to change is the port number for the www server
|
||||
# then your additional config file should look like this:
|
||||
# # production.yaml
|
||||
# www:
|
||||
# port: 80
|
||||
|
||||
# Process title visible in monitoring logs and process listing
|
||||
title: mailtrain
|
||||
|
||||
# Enabled HTML editors
|
||||
editors:
|
||||
- ckeditor
|
||||
- codeeditor
|
||||
- mosaico
|
||||
- mosaicoWithFsTemplate
|
||||
|
||||
# Default language to use
|
||||
language: en
|
||||
|
||||
# Inject custom scripts in subscription/layout.mjml.hbs
|
||||
# customSubscriptionScripts: [/custom/hello-world.js]
|
||||
|
||||
# If you start out as a root user (eg. if you want to use ports lower than 1000)
|
||||
# then you can downgrade the user once all services are up and running
|
||||
#user: mailtrain
|
||||
#group: mailtrain
|
||||
|
||||
# If Mailtrain is started as root, Reports feature drops the privileges of script generating the report to disallow
|
||||
# any modifications of Mailtrain code and even prohibits reading the production configuration (which contains the MySQL
|
||||
# password for read/write operations). The roUser/roGroup determines the user to be used
|
||||
#roUser: nobody
|
||||
#roGroup: nogroup
|
||||
|
||||
log:
|
||||
# silly|verbose|info|http|warn|error|silent
|
||||
level: verbose
|
||||
|
||||
www:
|
||||
# HTTP port to listen on
|
||||
port: 3000
|
||||
# HTTP port to listen on for sandboxed requests
|
||||
sandboxPort: 8081
|
||||
# HTTP interface to listen on
|
||||
host: 0.0.0.0
|
||||
# URL base for trusted urls. It must be absolute (starting with http:// or https://). If Mailtrain is served on
|
||||
# a non-standard port (e.g. 3000), the URL must also specify the port.
|
||||
trustedUrlBase: http://localhost:3000
|
||||
# URL base for sandbox urls. It must be absolute (starting with http:// or https://) and contain the sandbox port.
|
||||
sandboxUrlBase: http://localhost:8081
|
||||
|
||||
# Secret for signing the session ID cookie
|
||||
secret: a cat
|
||||
# Session length in seconds when remember me is checked
|
||||
remember: 2592000 # 30 days
|
||||
# logger interface for expressjs morgan
|
||||
log: dev
|
||||
# Is the server behind a proxy? true/false
|
||||
# Set this to true if you are serving Mailtrain as a virtual domain through Nginx or Apache
|
||||
proxy: false
|
||||
# maximum POST body size
|
||||
postSize: 2MB
|
||||
|
||||
mysql:
|
||||
host: localhost
|
||||
user: mailtrain
|
||||
password: mailtrain
|
||||
database: mailtrain
|
||||
# Some installations, eg. MAMP can use a different port (8889)
|
||||
# MAMP users should also turn on Allow network access to MySQL otherwise MySQL might not be accessible
|
||||
port: 3306
|
||||
charset: utf8mb4
|
||||
# The timezone configured on the MySQL server. This can be 'local', 'Z', or an offset in the form +HH:MM or -HH:MM
|
||||
timezone: local
|
||||
|
||||
redis:
|
||||
# enable to use Redis session cache or disable if Redis is not installed
|
||||
enabled: false
|
||||
host: localhost
|
||||
port: 6379
|
||||
db: 5
|
||||
# Uncomment if your Redis installation requires a password
|
||||
#password:
|
||||
|
||||
verp:
|
||||
# Enable to start an MX server that detects bounced messages using VERP
|
||||
# In most cases you do not want to use it
|
||||
# Requires root privileges
|
||||
enabled: false
|
||||
port: 2525
|
||||
host: 0.0.0.0
|
||||
# With DMARC, the Return-Path and From address must match the same domain.
|
||||
# By default we get around this by using the VERP address in the Sender header,
|
||||
# with the side effect that some email clients diplay an ugly on behalf of message.
|
||||
# You can safely disable this Sender header if you're not using DMARC or your
|
||||
# VERP hostname is in the same domain as the From address.
|
||||
# disablesenderheader: true
|
||||
|
||||
ldap:
|
||||
# enable to use ldap user backend
|
||||
enabled: false
|
||||
# method is ldapjs or ldapauth - it chooses the library to be used. If not given, it chooses the one present.
|
||||
# method: ldapjs
|
||||
host: localhost
|
||||
port: 3002
|
||||
baseDN: ou=users,dc=company
|
||||
filter: (|(username={{username}})(mail={{username}}))
|
||||
# Username field in LDAP (uid/cn/username)
|
||||
uidTag: username
|
||||
# nameTag identifies the attribute to be used for user's full name
|
||||
nameTag: username
|
||||
passwordresetlink:
|
||||
newUserRole: master
|
||||
# Global namespace id
|
||||
newUserNamespaceId: 1
|
||||
# Use a different user to bind LDAP (final bind DN will be: {{uidTag}}: {{bindUser}},{{baseDN}})
|
||||
bindUser: name@company.net
|
||||
bindPassword: mySecretPassword
|
||||
|
||||
postfixbounce:
|
||||
# Enable to allow writing Postfix bounce log to Mailtrain listener
|
||||
# If enabled, tail mail.log to Mailtrain with the following command:
|
||||
# tail -f -n +0 /var/log/mail.log | nc localhost 5699 -
|
||||
enabled: false
|
||||
port: 5699
|
||||
# allow connections from localhost only
|
||||
host: 127.0.0.1
|
||||
|
||||
# extra options for nodemailer
|
||||
nodemailer:
|
||||
#textEncoding: base64
|
||||
|
||||
queue:
|
||||
# How many parallel sender processes to spawn
|
||||
# You can use more than 1 process only if you have Redis enabled
|
||||
processes: 1
|
||||
|
||||
cors:
|
||||
# Allow subscription widgets to be embedded
|
||||
# origins: ['https://www.example.com']
|
||||
|
||||
mosaico:
|
||||
# Installed templates
|
||||
fsTemplates:
|
||||
- key: versafix-1
|
||||
label: Versafix One
|
||||
# Inject custom scripts
|
||||
# customscripts:
|
||||
# - /mosaico/custom/my-mosaico-plugin.js
|
||||
|
||||
grapejs:
|
||||
# Installed templates
|
||||
templates:
|
||||
- key: demo
|
||||
label: HTML Template
|
||||
- key: aves
|
||||
label: MJML Template
|
||||
|
||||
reports:
|
||||
# The whole reporting functionality can be disabled below if the they are not needed and the DB cannot be
|
||||
# properly protected.
|
||||
# Reports rely on custom user defined Javascript snippets defined in the report template. The snippets are run on the
|
||||
# server when generating a report. As these snippets are stored in the DB, they pose a security risk because they can
|
||||
# help gaining access to the server if the DB cannot
|
||||
# be properly protected (e.g. if it is shared with another application with security weaknesses).
|
||||
# Mailtrain mitigates this problem by running the custom Javascript snippets in a chrooted environment and under a
|
||||
# DB user that cannot modify the database (see userRO in [mysql] above). However the chrooted environment is available
|
||||
# only if Mailtrain is started as root. The chrooted environment still does not prevent the custom JS script in
|
||||
# performing network operations and in generating XSS attacks as part of the report.
|
||||
# The bottom line is that if people who are creating report templates or have write access to the DB cannot be trusted,
|
||||
# then it's safer to switch off the reporting functionality below.
|
||||
enabled: false
|
||||
|
||||
testserver:
|
||||
# Starts a vanity server that redirects all mail to /dev/null
|
||||
# Mostly needed for local development
|
||||
enabled: false
|
||||
port: 5587
|
||||
mailboxserverport: 3001
|
||||
host: 0.0.0.0
|
||||
username: testuser
|
||||
password: testpass
|
||||
logger: false
|
||||
|
||||
seleniumwebdriver:
|
||||
browser: phantomjs
|
||||
|
||||
|
||||
roles:
|
||||
global:
|
||||
master:
|
||||
name: Master
|
||||
admin: true
|
||||
description: All permissions
|
||||
permissions: [rebuildPermissions, createJavascriptWithROAccess, manageBlacklist, manageSettings]
|
||||
rootNamespaceRole: master
|
||||
|
||||
namespace:
|
||||
master:
|
||||
name: Master
|
||||
description: All permissions
|
||||
permissions: [view, edit, delete, share, createNamespace, createList, createCustomForm, createReport, createReportTemplate, createTemplate, createMosaicoTemplate, createSendConfiguration, createCampaign, manageUsers]
|
||||
children:
|
||||
sendConfiguration: [viewPublic, viewPrivate, edit, delete, share, sendWithoutOverrides, sendWithAllowedOverrides, sendWithAnyOverrides]
|
||||
list: [view, edit, delete, share, viewFields, manageFields, viewSubscriptions, manageSubscriptions, viewSegments, manageSegments]
|
||||
customForm: [view, edit, delete, share]
|
||||
campaign: [view, edit, delete, share, viewFiles, manageFiles, viewAttachments, manageAttachments, viewTriggers, manageTriggers, send, viewStats]
|
||||
template: [view, edit, delete, share, viewFiles, manageFiles]
|
||||
report: [view, edit, delete, share, execute, viewContent, viewOutput]
|
||||
reportTemplate: [view, edit, delete, share, execute]
|
||||
mosaicoTemplate: [view, edit, delete, share, viewFiles, manageFiles]
|
||||
namespace: [view, edit, delete, share, createNamespace, createList, createCustomForm, createReport, createReportTemplate, createTemplate, createMosaicoTemplate, createSendConfiguration, createCampaign, manageUsers]
|
||||
|
||||
sendConfiguration:
|
||||
master:
|
||||
name: Master
|
||||
description: All permissions
|
||||
permissions: [viewPublic, viewPrivate, edit, delete, share, sendWithoutOverrides, sendWithAllowedOverrides, sendWithAnyOverrides]
|
||||
|
||||
list:
|
||||
master:
|
||||
name: Master
|
||||
description: All permissions
|
||||
permissions: [view, edit, delete, share, viewFields, manageFields, viewSubscriptions, manageSubscriptions, viewSegments, manageSegments]
|
||||
|
||||
customForm:
|
||||
master:
|
||||
name: Master
|
||||
description: All permissions
|
||||
permissions: [view, edit, delete, share]
|
||||
|
||||
campaign:
|
||||
master:
|
||||
name: Master
|
||||
description: All permissions
|
||||
permissions: [view, edit, delete, share, viewFiles, manageFiles, viewAttachments, manageAttachments, viewTriggers, manageTriggers, send, viewStats]
|
||||
|
||||
template:
|
||||
master:
|
||||
name: Master
|
||||
description: All permissions
|
||||
permissions: [view, edit, delete, share, viewFiles, manageFiles]
|
||||
|
||||
report:
|
||||
master:
|
||||
name: Master
|
||||
description: All permissions
|
||||
permissions: [view, edit, delete, share, execute, viewContent, viewOutput]
|
||||
|
||||
reportTemplate:
|
||||
master:
|
||||
name: Master
|
||||
description: All permissions
|
||||
permissions: [view, edit, delete, share, execute]
|
||||
|
||||
mosaicoTemplate:
|
||||
master:
|
||||
name: Master
|
||||
description: All permissions
|
||||
permissions: [view, edit, delete, share, viewFiles, manageFiles]
|
||||
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
const config = require('config');
|
||||
const urllib = require('url');
|
||||
const {anonymousRestrictedAccessToken} = require('../shared/urls');
|
||||
|
||||
function getTrustedUrlBase() {
|
||||
return urllib.resolve(config.www.trustedUrlBase, '');
|
||||
|
@ -19,7 +20,7 @@ function getSandboxUrl(path, context) {
|
|||
if (context && context.user && context.user.restrictedAccessToken) {
|
||||
return urllib.resolve(config.www.sandboxUrlBase, context.user.restrictedAccessToken + '/' + (path || ''));
|
||||
} else {
|
||||
return urllib.resolve(config.www.sandboxUrlBase, 'ANONYMOUS/' + (path || ''));
|
||||
return urllib.resolve(config.www.sandboxUrlBase, anonymousRestrictedAccessToken + '/' + (path || ''));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -72,6 +72,19 @@ async function listWithContentDTAjax(context, params) {
|
|||
);
|
||||
}
|
||||
|
||||
async function listOthersByListDTAjax(context, campaignId, listId, params) {
|
||||
return await dtHelpers.ajaxListWithPermissions(
|
||||
context,
|
||||
[{ entityTypeId: 'campaign', requiredOperations: ['view'] }],
|
||||
params,
|
||||
builder => builder.from('campaigns')
|
||||
.innerJoin('namespaces', 'namespaces.id', 'campaigns.namespace')
|
||||
.whereNot('campaigns.id', campaignId)
|
||||
.where('campaigns.list', listId),
|
||||
['campaigns.id', 'campaigns.name', 'campaigns.description', 'campaigns.type', 'campaigns.created', 'namespaces.name']
|
||||
);
|
||||
}
|
||||
|
||||
async function getByIdTx(tx, context, id, withPermissions = true, content = Content.ALL) {
|
||||
await shares.enforceEntityPermissionTx(tx, context, 'campaign', id, 'view');
|
||||
let entity = await tx('campaigns').where('id', id).first();
|
||||
|
@ -309,8 +322,8 @@ async function enforceSendPermissionTx(tx, context, campaignId) {
|
|||
|
||||
const requiredPermission = getSendConfigurationPermissionRequiredForSend(campaign, sendConfiguration);
|
||||
|
||||
await shares.enforceEntityPermissionTx(tx, context, 'send_configuration', campaign.send_configuration, requiredPermission);
|
||||
await shares.enforceEntityPermissionTx(tx, context, 'campaign', campaignId, requiredPermission);
|
||||
await shares.enforceEntityPermissionTx(tx, context, 'sendConfiguration', campaign.send_configuration, requiredPermission);
|
||||
await shares.enforceEntityPermissionTx(tx, context, 'campaign', campaignId, 'send');
|
||||
}
|
||||
|
||||
// This is to handle circular dependency with triggers.js
|
||||
|
@ -319,6 +332,7 @@ Object.assign(module.exports, {
|
|||
hash,
|
||||
listDTAjax,
|
||||
listWithContentDTAjax,
|
||||
listOthersByListDTAjax,
|
||||
getByIdTx,
|
||||
getById,
|
||||
create,
|
||||
|
|
|
@ -10,6 +10,7 @@ const shares = require('./shares');
|
|||
const namespaceHelpers = require('../lib/namespace-helpers');
|
||||
const fields = require('./fields');
|
||||
const segments = require('./segments');
|
||||
const entitySettings = require('../lib/entity-settings');
|
||||
|
||||
const UnsubscriptionMode = require('../shared/lists').UnsubscriptionMode;
|
||||
|
||||
|
@ -21,6 +22,8 @@ function hash(entity) {
|
|||
|
||||
|
||||
async function listDTAjax(context, params) {
|
||||
const campaignEntityType = entitySettings.getEntityType('campaign');
|
||||
|
||||
return await dtHelpers.ajaxListWithPermissions(
|
||||
context,
|
||||
[{ entityTypeId: 'list', requiredOperations: ['view'] }],
|
||||
|
@ -28,7 +31,16 @@ async function listDTAjax(context, params) {
|
|||
builder => builder
|
||||
.from('lists')
|
||||
.innerJoin('namespaces', 'namespaces.id', 'lists.namespace'),
|
||||
['lists.id', 'lists.name', 'lists.cid', 'lists.subscribers', 'lists.description', 'namespaces.name']
|
||||
['lists.id', 'lists.name', 'lists.cid', 'lists.subscribers', 'lists.description', 'namespaces.name',
|
||||
{ query: builder =>
|
||||
builder.from('campaigns')
|
||||
.whereRaw('campaigns.list = lists.id')
|
||||
.innerJoin(campaignEntityType.permissionsTable, 'campaigns.id', `${campaignEntityType.permissionsTable}.entity`)
|
||||
.where(`${campaignEntityType.permissionsTable}.operation`, 'viewTriggers')
|
||||
.innerJoin('triggers', 'campaigns.id', 'triggers.campaign')
|
||||
.count()
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -6,10 +6,10 @@ const { enforce, filterObject } = require('../lib/helpers');
|
|||
const dtHelpers = require('../lib/dt-helpers');
|
||||
const interoperableErrors = require('../shared/interoperable-errors');
|
||||
const shares = require('./shares');
|
||||
const {EntityVals, ActionVals} = require('../shared/triggers');
|
||||
const {EntityVals, EventVals, Entity} = require('../shared/triggers');
|
||||
const campaigns = require('./campaigns');
|
||||
|
||||
const allowedKeys = new Set(['name', 'description', 'entity', 'action', 'seconds_after', 'enabled', 'source_campaign']);
|
||||
const allowedKeys = new Set(['name', 'description', 'entity', 'event', 'seconds_after', 'enabled', 'source_campaign']);
|
||||
|
||||
function hash(entity) {
|
||||
return hasher.hash(filterObject(entity, allowedKeys));
|
||||
|
@ -34,9 +34,10 @@ async function listByCampaignDTAjax(context, campaignId, params) {
|
|||
params,
|
||||
builder => builder
|
||||
.from('triggers')
|
||||
.innerJoin('campaigns', 'campaign.id', 'triggers.campaign')
|
||||
.innerJoin('campaigns', 'campaigns.id', 'triggers.campaign')
|
||||
.innerJoin('lists', 'lists.id', 'campaigns.list')
|
||||
.where('triggers.campaign', campaignId),
|
||||
[ 'triggers.id', 'triggers.name', 'campaign.list', 'triggers.entity', 'triggers.action', 'triggers.source_campaign', 'triggers.enabled' ]
|
||||
[ 'triggers.id', 'triggers.name', 'triggers.description', 'lists.name', 'triggers.entity', 'triggers.event', 'triggers.seconds_after', 'triggers.enabled' ]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -44,13 +45,13 @@ async function listByCampaignDTAjax(context, campaignId, params) {
|
|||
async function listByListDTAjax(context, listId, params) {
|
||||
return await dtHelpers.ajaxListWithPermissions(
|
||||
context,
|
||||
[{ entityTypeId: 'campaign', requiredOperations: ['view'] }],
|
||||
[{ entityTypeId: 'campaign', requiredOperations: ['viewTriggers'] }],
|
||||
params,
|
||||
builder => builder
|
||||
.from('triggers')
|
||||
.innerJoin('campaigns', 'campaign.id', 'triggers.campaign')
|
||||
.where('campaign.list', listId),
|
||||
[ 'triggers.id', 'triggers.name', 'trigger.campaign', 'triggers.entity', 'triggers.action', 'triggers.source_campaign', 'triggers.enabled' ]
|
||||
.innerJoin('campaigns', 'campaigns.id', 'triggers.campaign')
|
||||
.where('campaigns.list', listId),
|
||||
[ 'triggers.id', 'triggers.name', 'triggers.description', 'campaigns.name', 'triggers.entity', 'triggers.event', 'triggers.seconds_after', 'triggers.enabled', 'triggers.campaign' ]
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -58,7 +59,7 @@ async function _validateAndPreprocess(tx, context, campaignId, entity) {
|
|||
enforce(Number.isInteger(entity.seconds_after));
|
||||
enforce(entity.seconds_after >= 0, 'Seconds after must not be negative');
|
||||
enforce(entity.entity in EntityVals, 'Invalid entity');
|
||||
enforce(entity.action in ActionVals[entity.entity], 'Invalid action');
|
||||
enforce(entity.event in EventVals[entity.entity], 'Invalid event');
|
||||
|
||||
if (entity.entity === Entity.CAMPAIGN) {
|
||||
await shares.enforceEntityPermissionTx(tx, context, 'campaign', entity.source_campaign, 'view');
|
||||
|
@ -94,7 +95,7 @@ async function updateWithConsistencyCheck(context, campaignId, entity) {
|
|||
await knex.transaction(async tx => {
|
||||
await shares.enforceEntityPermissionTx(tx, context, 'campaign', campaignId, 'manageTriggers');
|
||||
|
||||
const existing = await tx('campaign').where({campaign: campaignId, id: entity.id}).first();
|
||||
const existing = await tx('triggers').where({campaign: campaignId, id: entity.id}).first();
|
||||
if (!existing) {
|
||||
throw new interoperableErrors.NotFoundError();
|
||||
}
|
||||
|
@ -138,7 +139,8 @@ Object.assign(module.exports, {
|
|||
hash,
|
||||
getById,
|
||||
listByCampaignDTAjax,
|
||||
listByListDTAjax, create,
|
||||
listByListDTAjax,
|
||||
create,
|
||||
updateWithConsistencyCheck,
|
||||
removeTx,
|
||||
remove,
|
||||
|
|
|
@ -14,6 +14,11 @@ router.postAsync('/campaigns-with-content-table', passport.loggedIn, async (req,
|
|||
return res.json(await campaigns.listWithContentDTAjax(req.context, req.body));
|
||||
});
|
||||
|
||||
router.postAsync('/campaigns-others-by-list-table/:campaignId/:listId', passport.loggedIn, async (req, res) => {
|
||||
return res.json(await campaigns.listOthersByListDTAjax(req.context, req.params.campaignId, req.params.listId, req.body));
|
||||
});
|
||||
|
||||
|
||||
router.getAsync('/campaigns-settings/:campaignId', passport.loggedIn, async (req, res) => {
|
||||
const campaign = await campaigns.getById(req.context, req.params.campaignId, true, campaigns.Content.WITHOUT_SOURCE_CUSTOM);
|
||||
campaign.hash = campaigns.hash(campaign, campaigns.Content.WITHOUT_SOURCE_CUSTOM);
|
||||
|
|
|
@ -7,7 +7,7 @@ const entityTypesAddNamespace = ['list', 'custom_form', 'template', 'campaign',
|
|||
const shareableEntityTypes = ['list', 'custom_form', 'template', 'campaign', 'report', 'report_template', 'namespace', 'send_configuration', 'mosaico_template'];
|
||||
const { MailerType, getSystemSendConfigurationId } = require('../../../shared/send-configurations');
|
||||
const { enforce } = require('../../../lib/helpers');
|
||||
const { EntityVals: TriggerEntityVals, ActionVals: TriggerActionVals } = require('../../../shared/triggers');
|
||||
const { EntityVals: TriggerEntityVals, EventVals: TriggerEventVals } = require('../../../shared/triggers');
|
||||
|
||||
const entityTypesWithFiles = {
|
||||
campaign: {
|
||||
|
@ -951,7 +951,7 @@ async function migrateAttachments(knex) {
|
|||
async function migrateTriggers(knex) {
|
||||
await knex.schema.table('triggers', table => {
|
||||
table.renameColumn('rule', 'entity');
|
||||
table.renameColumn('column', 'action');
|
||||
table.renameColumn('column', 'event');
|
||||
table.renameColumn('dest_campaign', 'campaign');
|
||||
table.renameColumn('seconds', 'seconds_after');
|
||||
});
|
||||
|
@ -964,7 +964,7 @@ async function migrateTriggers(knex) {
|
|||
enforce(campaign.list === trigger.list, 'The list of trigger and campaign have to be the same.');
|
||||
|
||||
enforce(trigger.entity in TriggerEntityVals);
|
||||
enforce(trigger.action in TriggerActionVals[trigger.entity]);
|
||||
enforce(trigger.event in TriggerEventVals[trigger.entity]);
|
||||
}
|
||||
|
||||
await knex.schema.table('triggers', table => {
|
||||
|
|
|
@ -5,7 +5,7 @@ const Entity = {
|
|||
CAMPAIGN: 'campaign'
|
||||
};
|
||||
|
||||
const Action = {
|
||||
const Event = {
|
||||
[Entity.SUBSCRIPTION]: {
|
||||
CREATED: 'created',
|
||||
LATEST_OPEN: 'latest_open',
|
||||
|
@ -25,7 +25,7 @@ const EntityVals = {
|
|||
campaign: 'CAMPAIGN'
|
||||
};
|
||||
|
||||
const ActionVals = {
|
||||
const EventVals = {
|
||||
[Entity.SUBSCRIPTION]: {
|
||||
created: 'CREATED',
|
||||
latest_open: 'LATEST_OPEN',
|
||||
|
@ -42,7 +42,7 @@ const ActionVals = {
|
|||
|
||||
module.exports = {
|
||||
Entity,
|
||||
Action,
|
||||
Event,
|
||||
EntityVals,
|
||||
ActionVals
|
||||
EventVals
|
||||
};
|
7
shared/urls.js
Normal file
7
shared/urls.js
Normal file
|
@ -0,0 +1,7 @@
|
|||
'use strict';
|
||||
|
||||
const anonymousRestrictedAccessToken = 'public';
|
||||
|
||||
module.exports = {
|
||||
anonymousRestrictedAccessToken
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue