Added support for Datatables
Added support for ajax-based server side validation (useful for validation of emails, duplicate usernames, etc.) User form more or less ready in the basic version (i.e. without permission management)
This commit is contained in:
parent
f776170854
commit
c81f5544e6
26 changed files with 1097 additions and 167 deletions
8
app.js
8
app.js
|
@ -21,7 +21,7 @@ const passport = require('./lib/passport');
|
|||
const tools = require('./lib/tools');
|
||||
|
||||
const routes = require('./routes/index');
|
||||
const users = require('./routes/users');
|
||||
const usersOld = require('./routes/users-legacy');
|
||||
const lists = require('./routes/lists');
|
||||
const settings = require('./routes/settings');
|
||||
const settingsModel = require('./lib/models/settings');
|
||||
|
@ -42,7 +42,9 @@ const grapejs = require('./routes/grapejs');
|
|||
const mosaico = require('./routes/mosaico');
|
||||
const reports = require('./routes/reports');
|
||||
const reportsTemplates = require('./routes/report-templates');
|
||||
|
||||
const namespaces = require('./routes/namespaces');
|
||||
const users = require('./routes/users');
|
||||
|
||||
const interoperableErrors = require('./shared/interoperable-errors');
|
||||
|
||||
|
@ -206,7 +208,7 @@ app.use((req, res, next) => {
|
|||
});
|
||||
|
||||
app.use('/', routes);
|
||||
app.use('/users', users);
|
||||
app.use('/users', usersOld);
|
||||
app.use('/lists', lists);
|
||||
app.use('/templates', templates);
|
||||
app.use('/campaigns', campaigns);
|
||||
|
@ -224,7 +226,9 @@ app.use('/api', api);
|
|||
app.use('/editorapi', editorapi);
|
||||
app.use('/grapejs', grapejs);
|
||||
app.use('/mosaico', mosaico);
|
||||
|
||||
app.use('/namespaces', namespaces);
|
||||
app.use('/users', users);
|
||||
|
||||
if (config.reports && config.reports.enabled === true) {
|
||||
app.use('/reports', reports);
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build": "webpack",
|
||||
"watch": "webpack --watch --watch-poll",
|
||||
"watch": "webpack --watch",
|
||||
"locales": "for moFile in ../languages/*.mo; do lng=`basename $moFile .mo`; i18next-conv -s $moFile -t locales/$lng/common.json -l $lng; done"
|
||||
},
|
||||
"repository": {
|
||||
|
@ -17,9 +17,14 @@
|
|||
"homepage": "https://mailtrain.org/",
|
||||
"dependencies": {
|
||||
"axios": "^0.16.1",
|
||||
"datatables.net": "^1.10.15",
|
||||
"datatables.net-bs": "^1.10.15",
|
||||
"datatables.net-select": "^1.2.2",
|
||||
"datatables.net-select-bs": "^1.2.2",
|
||||
"i18next": "^8.3.0",
|
||||
"i18next-xhr-backend": "^1.4.1",
|
||||
"immutable": "^3.8.1",
|
||||
"owasp-password-strength-test": "github:bures/owasp-password-strength-test",
|
||||
"prop-types": "^15.5.10",
|
||||
"react": "^15.5.4",
|
||||
"react-dom": "^15.5.4",
|
||||
|
|
|
@ -268,12 +268,16 @@ function withForm(target) {
|
|||
isValidationShown: false,
|
||||
isDisabled: false,
|
||||
statusMessageText: '',
|
||||
data: Immutable.Map()
|
||||
data: Immutable.Map(),
|
||||
isServerValidationRunning: false
|
||||
});
|
||||
|
||||
inst.initFormState = function() {
|
||||
inst.initFormState = function(serverValidationUrl, serverValidationAttrs) {
|
||||
const state = this.state || {};
|
||||
state.formState = cleanFormState;
|
||||
if (serverValidationUrl) {
|
||||
state.formStateServerValidation = { url: serverValidationUrl, attrs: serverValidationAttrs };
|
||||
}
|
||||
this.state = state;
|
||||
};
|
||||
|
||||
|
@ -294,7 +298,7 @@ function withForm(target) {
|
|||
});
|
||||
}, 500);
|
||||
|
||||
const response = await axios.get(url)
|
||||
const response = await axios.get(url);
|
||||
|
||||
const data = response.data;
|
||||
|
||||
|
@ -309,6 +313,8 @@ function withForm(target) {
|
|||
};
|
||||
|
||||
inst.validateAndSendFormValuesToURL = async function(method, url, mutator) {
|
||||
await this.waitForFormServerValidated();
|
||||
|
||||
if (this.isFormWithoutErrors()) {
|
||||
const data = this.getFormValues();
|
||||
|
||||
|
@ -331,28 +337,113 @@ function withForm(target) {
|
|||
|
||||
inst.populateFormValues = function(data) {
|
||||
this.setState(previousState => ({
|
||||
formState: previousState.formState.withMutations(state => {
|
||||
state.set('state', FormState.Ready);
|
||||
formState: previousState.formState.withMutations(mutState => {
|
||||
mutState.set('state', FormState.Ready);
|
||||
|
||||
state.update('data', stateData => stateData.withMutations(mutableStateData => {
|
||||
mutState.update('data', stateData => stateData.withMutations(mutStateData => {
|
||||
for (const key in data) {
|
||||
mutableStateData.set(key, Immutable.Map({
|
||||
mutStateData.set(key, Immutable.Map({
|
||||
value: data[key]
|
||||
}));
|
||||
}
|
||||
|
||||
this.validateFormValues(mutableStateData);
|
||||
}));
|
||||
|
||||
this.validateForm(mutState);
|
||||
})
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
// formValidateResolve is called by "validateForm" once client receives validation response from server that does not
|
||||
// trigger another server validation
|
||||
let formValidateResolve = null;
|
||||
|
||||
const scheduleValidateForm = (self) => {
|
||||
setTimeout(() => {
|
||||
self.setState(previousState => ({
|
||||
formState: previousState.formState.withMutations(mutState => {
|
||||
self.validateForm(mutState);
|
||||
})
|
||||
}));
|
||||
}, 0);
|
||||
};
|
||||
|
||||
inst.waitForFormServerValidated = async function() {
|
||||
if (!this.isFormServerValidated()) {
|
||||
await new Promise(resolve => { formValidateResolve = resolve; });
|
||||
}
|
||||
};
|
||||
|
||||
inst.validateForm = function(mutState) {
|
||||
const serverValidation = this.state.formStateServerValidation;
|
||||
|
||||
if (!mutState.get('isServerValidationRunning') && serverValidation) {
|
||||
const payload = {};
|
||||
let payloadNotEmpty = false;
|
||||
|
||||
for (const attr of serverValidation.attrs) {
|
||||
const currValue = mutState.getIn(['data', attr, 'value']);
|
||||
const serverValue = mutState.getIn(['data', attr, 'serverValue']);
|
||||
|
||||
// This really assumes that all form values are preinitialized (i.e. not undef)
|
||||
if (currValue !== serverValue) {
|
||||
mutState.setIn(['data', attr, 'serverValidated'], false);
|
||||
payload[attr] = currValue;
|
||||
payloadNotEmpty = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (payloadNotEmpty) {
|
||||
mutState.set('isServerValidationRunning', true);
|
||||
|
||||
axios.post(serverValidation.url, payload)
|
||||
.then(response => {
|
||||
|
||||
this.setState(previousState => ({
|
||||
formState: previousState.formState.withMutations(mutState => {
|
||||
mutState.set('isServerValidationRunning', false);
|
||||
|
||||
mutState.update('data', stateData => stateData.withMutations(mutStateData => {
|
||||
for (const attr in payload) {
|
||||
mutStateData.setIn([attr, 'serverValue'], payload[attr]);
|
||||
|
||||
if (payload[attr] === mutState.getIn(['data', attr, 'value'])) {
|
||||
mutStateData.setIn([attr, 'serverValidated'], true);
|
||||
mutStateData.setIn([attr, 'serverValidation'], response.data[attr] || true);
|
||||
}
|
||||
}
|
||||
}));
|
||||
})
|
||||
}));
|
||||
|
||||
scheduleValidateForm(this);
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Ignoring unhandled error in "validateForm": ' + error);
|
||||
scheduleValidateForm(this);
|
||||
});
|
||||
} else {
|
||||
if (formValidateResolve) {
|
||||
const resolve = formValidateResolve;
|
||||
formValidateResolve = null;
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.localValidateFormValues) {
|
||||
mutState.update('data', stateData => stateData.withMutations(mutStateData => {
|
||||
this.localValidateFormValues(mutStateData);
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
inst.updateFormValue = function(key, value) {
|
||||
this.setState(previousState => ({
|
||||
formState: previousState.formState.update('data', stateData => stateData.withMutations(mutableStateData => {
|
||||
mutableStateData.setIn([key, 'value'], value);
|
||||
this.validateFormValues(mutableStateData);
|
||||
}))
|
||||
formState: previousState.formState.withMutations(mutState => {
|
||||
mutState.setIn(['data', key, 'value'], value);
|
||||
this.validateForm(mutState);
|
||||
})
|
||||
}));
|
||||
};
|
||||
|
||||
|
@ -417,6 +508,10 @@ function withForm(target) {
|
|||
return !this.state.formState.get('data').find(attr => attr.get('error'));
|
||||
};
|
||||
|
||||
inst.isFormServerValidated = function() {
|
||||
return this.state.formStateServerValidation.attrs.every(attr => this.state.formState.getIn(['data', attr, 'serverValidated']));
|
||||
};
|
||||
|
||||
inst.getFormStatusMessageText = function() {
|
||||
return this.state.formState.get('statusMessageText');
|
||||
};
|
||||
|
|
0
client/src/lib/table.css
Normal file
0
client/src/lib/table.css
Normal file
171
client/src/lib/table.js
Normal file
171
client/src/lib/table.js
Normal file
|
@ -0,0 +1,171 @@
|
|||
'use strict';
|
||||
|
||||
import React, { Component } from 'react';
|
||||
import ReactDOMServer from 'react-dom/server';
|
||||
import { translate } from 'react-i18next';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import jQuery from 'jquery';
|
||||
import '../../public/jquery/jquery-ui-1.12.1.min.js';
|
||||
|
||||
import 'datatables.net';
|
||||
import 'datatables.net-bs';
|
||||
import 'datatables.net-bs/css/dataTables.bootstrap.css';
|
||||
import 'datatables.net-select';
|
||||
import 'datatables.net-select-bs/css/select.bootstrap.css';
|
||||
|
||||
import './table.css';
|
||||
import axios from './axios';
|
||||
|
||||
import { withPageHelpers } from '../lib/page'
|
||||
import { withErrorHandling, withAsyncErrorHandler } from './error-handling';
|
||||
|
||||
//dtFactory();
|
||||
//dtSelectFactory();
|
||||
|
||||
|
||||
const TableSelectMode = {
|
||||
NONE: 0,
|
||||
SINGLE: 1,
|
||||
MULTI: 2
|
||||
};
|
||||
|
||||
|
||||
@translate()
|
||||
@withPageHelpers
|
||||
@withErrorHandling
|
||||
class Table extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
// Select Mode simply cannot be changed later. This is just to make sure we avoid inconsistencies if someone changes it anyway.
|
||||
this.selectMode = this.props.selectMode;
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
selectMode: TableSelectMode.NONE
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
dataUrl: PropTypes.string,
|
||||
data: PropTypes.array,
|
||||
columns: PropTypes.array,
|
||||
selectMode: PropTypes.number,
|
||||
selection: PropTypes.oneOfType([PropTypes.array, PropTypes.string, PropTypes.number]),
|
||||
onSelectionChangedAsync: PropTypes.func,
|
||||
actionLinks: PropTypes.array,
|
||||
withHeader: PropTypes.bool
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
return this.props.selection !== nextProps.selection || this.props.data != nextProps.data || this.props.dataUrl != nextProps.dataUrl;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
|
||||
const columns = this.props.columns.slice();
|
||||
|
||||
if (this.props.actionLinks) {
|
||||
const actionLinks = this.props.actionLinks;
|
||||
|
||||
const createdCellFn = (td, data) => {
|
||||
const linksContainer = jQuery('<span class="mt-action-links"/>');
|
||||
for (const {label, link} of actionLinks) {
|
||||
const dest = link(data);
|
||||
const lnkHtml = ReactDOMServer.renderToStaticMarkup(<a href={dest}>{label}</a>);
|
||||
const lnk = jQuery(lnkHtml);
|
||||
lnk.click((evt) => { evt.preventDefault(); this.navigateTo(dest) });
|
||||
linksContainer.append(lnk);
|
||||
}
|
||||
|
||||
jQuery(td).html(linksContainer);
|
||||
};
|
||||
|
||||
columns.push({
|
||||
data: null,
|
||||
orderable: false,
|
||||
searchable: false,
|
||||
type: 'html',
|
||||
createdCell: createdCellFn
|
||||
});
|
||||
}
|
||||
|
||||
const dtOptions = {
|
||||
columns
|
||||
};
|
||||
|
||||
if (this.selectMode === TableSelectMode.MULTI) {
|
||||
dtOptions.select = {
|
||||
style: 'os'
|
||||
}
|
||||
} else if (this.selectMode === TableSelectMode.SINGLE) {
|
||||
dtOptions.select = {
|
||||
style: 'single'
|
||||
}
|
||||
}
|
||||
|
||||
if (this.props.data) {
|
||||
dtOptions.data = this.props.data;
|
||||
} else {
|
||||
dtOptions.serverSide = true;
|
||||
dtOptions.ajax = {
|
||||
url: this.props.dataUrl,
|
||||
type: 'POST'
|
||||
};
|
||||
}
|
||||
|
||||
this.table = jQuery(this.domTable).DataTable(dtOptions);
|
||||
|
||||
this.table.on('select.dt', ::this.onSelect);
|
||||
this.table.on('deselect.dt', ::this.onSelect);
|
||||
|
||||
this.updateSelection();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this.props.data) {
|
||||
this.table.clear();
|
||||
this.table.rows.add(this.props.data);
|
||||
}
|
||||
|
||||
this.updateSelection();
|
||||
}
|
||||
|
||||
updateSelection() {
|
||||
/*
|
||||
const tree = this.tree;
|
||||
if (this.selectMode === TableSelectMode.MULTI) {
|
||||
const selectSet = new Set(this.props.selection);
|
||||
|
||||
tree.enableUpdate(false);
|
||||
tree.visit(node => node.setSelected(selectSet.has(node.key)));
|
||||
tree.enableUpdate(true);
|
||||
|
||||
} else if (this.selectMode === TableSelectMode.SINGLE) {
|
||||
this.tree.activateKey(this.props.selection);
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
async onSelect(event, data) {
|
||||
const sel = this.table.rows( { selected: true } ).data();
|
||||
|
||||
if (this.props.onSelectionChangedAsync) {
|
||||
await this.props.onSelectionChangedAsync(sel);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
const props = this.props;
|
||||
|
||||
return (
|
||||
<table ref={(domElem) => { this.domTable = domElem; }} className="table table-striped table-bordered" cellSpacing="0" width="100%" />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableSelectMode
|
||||
}
|
|
@ -7,7 +7,7 @@ import PropTypes from 'prop-types';
|
|||
|
||||
import jQuery from 'jquery';
|
||||
import '../../public/jquery/jquery-ui-1.12.1.min.js';
|
||||
import '../../public/fancytree/jquery.fancytree-all.js';
|
||||
import '../../public/fancytree/jquery.fancytree-all.min.js';
|
||||
import '../../public/fancytree/skin-bootstrap/ui.fancytree.min.css';
|
||||
import './tree.css';
|
||||
import axios from './axios';
|
||||
|
@ -108,12 +108,13 @@ class TreeTable extends Component {
|
|||
const tdList = jQuery(node.tr).find(">td");
|
||||
|
||||
const linksContainer = jQuery('<span class="mt-action-links"/>');
|
||||
const links = actionLinks.map(({label, link}) => {
|
||||
const lnkHtml = ReactDOMServer.renderToStaticMarkup(<a href="">{label}</a>);
|
||||
for (const {label, link} of actionLinks) {
|
||||
const dest = link(node.key);
|
||||
const lnkHtml = ReactDOMServer.renderToStaticMarkup(<a href={dest}>{label}</a>);
|
||||
const lnk = jQuery(lnkHtml);
|
||||
lnk.click((evt) => { evt.preventDefault(); this.navigateTo(link(node.key)) });
|
||||
lnk.click((evt) => { evt.preventDefault(); this.navigateTo(dest) });
|
||||
linksContainer.append(lnk);
|
||||
});
|
||||
}
|
||||
|
||||
tdList.eq(1).html(linksContainer);
|
||||
};
|
||||
|
|
|
@ -7,7 +7,7 @@ import { withForm, Form, FormSendMethod, InputField, TextArea, ButtonRow, Button
|
|||
import axios from '../lib/axios';
|
||||
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
|
||||
import interoperableErrors from '../../../shared/interoperable-errors';
|
||||
import { ModalDialog } from "../lib/bootstrap-components";
|
||||
import { ModalDialog } from '../lib/bootstrap-components';
|
||||
|
||||
@translate()
|
||||
@withForm
|
||||
|
@ -20,7 +20,7 @@ export default class CUD extends Component {
|
|||
this.state = {};
|
||||
|
||||
if (props.edit) {
|
||||
this.state.nsId = parseInt(props.match.params.nsId);
|
||||
this.state.entityId = parseInt(props.match.params.id);
|
||||
}
|
||||
|
||||
this.initFormState();
|
||||
|
@ -29,7 +29,7 @@ export default class CUD extends Component {
|
|||
}
|
||||
|
||||
isEditGlobal() {
|
||||
return this.state.nsId === 1;
|
||||
return this.state.entityId === 1;
|
||||
}
|
||||
|
||||
isDelete() {
|
||||
|
@ -40,7 +40,7 @@ export default class CUD extends Component {
|
|||
for (let idx = 0; idx < data.length; idx++) {
|
||||
const entry = data[idx];
|
||||
|
||||
if (entry.key === this.state.nsId) {
|
||||
if (entry.key === this.state.entityId) {
|
||||
if (entry.children.length > 0) {
|
||||
this.hasChildren = true;
|
||||
}
|
||||
|
@ -57,7 +57,7 @@ export default class CUD extends Component {
|
|||
|
||||
@withAsyncErrorHandler
|
||||
async loadTreeData() {
|
||||
axios.get("/namespaces/rest/namespacesTree")
|
||||
axios.get('/namespaces/rest/namespacesTree')
|
||||
.then(response => {
|
||||
|
||||
response.data.expanded = true;
|
||||
|
@ -75,7 +75,7 @@ export default class CUD extends Component {
|
|||
|
||||
@withAsyncErrorHandler
|
||||
async loadFormValues() {
|
||||
await this.getFormValuesFromURL(`/namespaces/rest/namespaces/${this.state.nsId}`, data => {
|
||||
await this.getFormValuesFromURL(`/namespaces/rest/namespaces/${this.state.entityId}`, data => {
|
||||
if (data.parent) data.parent = data.parent.toString();
|
||||
});
|
||||
}
|
||||
|
@ -96,7 +96,7 @@ export default class CUD extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
validateFormValues(state) {
|
||||
localValidateFormValues(state) {
|
||||
const t = this.props.t;
|
||||
|
||||
if (!state.getIn(['name', 'value']).trim()) {
|
||||
|
@ -121,7 +121,7 @@ export default class CUD extends Component {
|
|||
let sendMethod, url;
|
||||
if (edit) {
|
||||
sendMethod = FormSendMethod.PUT;
|
||||
url = `/namespaces/rest/namespaces/${this.state.nsId}`
|
||||
url = `/namespaces/rest/namespaces/${this.state.entityId}`
|
||||
} else {
|
||||
sendMethod = FormSendMethod.POST;
|
||||
url = '/namespaces/rest/namespaces'
|
||||
|
@ -159,11 +159,11 @@ export default class CUD extends Component {
|
|||
}
|
||||
|
||||
async showDeleteModal() {
|
||||
this.navigateTo(`/namespaces/edit/${this.state.nsId}/delete`);
|
||||
this.navigateTo(`/namespaces/edit/${this.state.entityId}/delete`);
|
||||
}
|
||||
|
||||
async hideDeleteModal() {
|
||||
this.navigateTo(`/namespaces/edit/${this.state.nsId}`);
|
||||
this.navigateTo(`/namespaces/edit/${this.state.entityId}`);
|
||||
}
|
||||
|
||||
async performDelete() {
|
||||
|
@ -175,7 +175,7 @@ export default class CUD extends Component {
|
|||
this.disableForm();
|
||||
this.setFormStatusMessage('info', t('Deleting namespace...'));
|
||||
|
||||
await axios.delete(`/namespaces/rest/namespaces/${this.state.nsId}`);
|
||||
await axios.delete(`/namespaces/rest/namespaces/${this.state.entityId}`);
|
||||
|
||||
this.navigateToWithFlashMessage('/namespaces', 'success', t('Namespace deleted'));
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@ const getStructure = t => ({
|
|||
children: {
|
||||
'edit' : {
|
||||
title: t('Edit Namespace'),
|
||||
params: [':nsId', ':action?'],
|
||||
params: [':id', ':action?'],
|
||||
render: props => (<CUD edit {...props} />)
|
||||
},
|
||||
'create' : {
|
||||
|
|
212
client/src/users/CUD.js
Normal file
212
client/src/users/CUD.js
Normal file
|
@ -0,0 +1,212 @@
|
|||
'use strict';
|
||||
|
||||
import React, { Component } from 'react';
|
||||
import { translate } from 'react-i18next';
|
||||
import { withPageHelpers, Title } from '../lib/page'
|
||||
import { withForm, Form, FormSendMethod, InputField, TextArea, ButtonRow, Button, TreeTableSelect } from '../lib/form';
|
||||
import axios from '../lib/axios';
|
||||
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
|
||||
import interoperableErrors from '../../../shared/interoperable-errors';
|
||||
import passwordValidator from '../../../shared/password-validator';
|
||||
import { ModalDialog } from '../lib/bootstrap-components';
|
||||
|
||||
@translate()
|
||||
@withForm
|
||||
@withPageHelpers
|
||||
@withErrorHandling
|
||||
export default class CUD extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.passwordValidator = passwordValidator(props.t);
|
||||
|
||||
this.state = {};
|
||||
|
||||
if (props.edit) {
|
||||
this.state.entityId = parseInt(props.match.params.id);
|
||||
}
|
||||
|
||||
this.initFormState('/users/rest/validate', ['username', 'email']);
|
||||
this.hasChildren = false;
|
||||
}
|
||||
|
||||
isDelete() {
|
||||
return this.props.match.params.action === 'delete';
|
||||
}
|
||||
|
||||
@withAsyncErrorHandler
|
||||
async loadFormValues() {
|
||||
await this.getFormValuesFromURL(`/users/rest/users/${this.state.entityId}`);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.edit) {
|
||||
this.loadFormValues();
|
||||
} else {
|
||||
this.populateFormValues({
|
||||
username: '',
|
||||
name: '',
|
||||
email: ''
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
localValidateFormValues(state) {
|
||||
const t = this.props.t;
|
||||
const edit = this.props.edit;
|
||||
|
||||
|
||||
const username = state.getIn(['username', 'value']);
|
||||
const usernamePattern = /^[a-zA-Z0-9][a-zA-Z0-9_\-.]*$/;
|
||||
const usernameServerValidation = state.getIn(['username', 'serverValidation']);
|
||||
|
||||
if (!username) {
|
||||
state.setIn(['username', 'error'], t('User name must not be empty'));
|
||||
} else if (!usernamePattern.test(username)) {
|
||||
state.setIn(['username', 'error'], t('User name may contain only the following characters: A-Z, a-z, 0-9, "_", "-", "." and may start only with A-Z, a-z, 0-9.'));
|
||||
} else if (!usernameServerValidation || usernameServerValidation.exists) {
|
||||
state.setIn(['username', 'error'], t('The user name already exists in the system.'));
|
||||
} else {
|
||||
state.setIn(['username', 'error'], null);
|
||||
}
|
||||
|
||||
|
||||
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 {
|
||||
state.setIn(['email', 'error'], null);
|
||||
}
|
||||
|
||||
|
||||
const name = state.getIn(['name', 'value']);
|
||||
|
||||
if (!name) {
|
||||
state.setIn(['name', 'error'], t('Full name must not be empty'));
|
||||
} else {
|
||||
state.setIn(['name', 'error'], null);
|
||||
}
|
||||
|
||||
|
||||
const password = state.getIn(['password', 'value']) || '';
|
||||
const password2 = state.getIn(['password2', 'value']) || '';
|
||||
|
||||
const passwordResults = this.passwordValidator.test(password);
|
||||
|
||||
let passwordMsgs = [];
|
||||
|
||||
if (!edit && !password) {
|
||||
passwordMsgs.push(t('Password must not be empty'));
|
||||
}
|
||||
|
||||
if (password) {
|
||||
passwordMsgs.push(...passwordResults.errors);
|
||||
}
|
||||
|
||||
if (passwordMsgs.length > 1) {
|
||||
passwordMsgs = passwordMsgs.map((msg, idx) => <div key={idx}>{msg}</div>)
|
||||
}
|
||||
|
||||
state.setIn(['password', 'error'], passwordMsgs.length > 0 ? passwordMsgs : null);
|
||||
state.setIn(['password2', 'error'], password !== password2 ? t('Passwords must match') : null);
|
||||
}
|
||||
|
||||
async submitHandler() {
|
||||
const t = this.props.t;
|
||||
const edit = this.props.edit;
|
||||
|
||||
let sendMethod, url;
|
||||
if (edit) {
|
||||
sendMethod = FormSendMethod.PUT;
|
||||
url = `/users/rest/users/${this.state.entityId}`
|
||||
} else {
|
||||
sendMethod = FormSendMethod.POST;
|
||||
url = '/users/rest/users'
|
||||
}
|
||||
|
||||
try {
|
||||
this.disableForm();
|
||||
this.setFormStatusMessage('info', t('Saving user ...'));
|
||||
|
||||
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url);
|
||||
|
||||
if (submitSuccessful) {
|
||||
this.navigateToWithFlashMessage('/users', 'success', t('User saved'));
|
||||
} else {
|
||||
this.enableForm();
|
||||
this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.'));
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof interoperableErrors.LoopDetectedError) {
|
||||
this.setFormStatusMessage('danger',
|
||||
<span>
|
||||
<strong>{t('Your updates cannot be saved.')}</strong>{' '}
|
||||
{t('The username is already assigned to another user.')}
|
||||
</span>
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async showDeleteModal() {
|
||||
this.navigateTo(`/users/edit/${this.state.entityId}/delete`);
|
||||
}
|
||||
|
||||
async hideDeleteModal() {
|
||||
this.navigateTo(`/users/edit/${this.state.entityId}`);
|
||||
}
|
||||
|
||||
async performDelete() {
|
||||
const t = this.props.t;
|
||||
|
||||
await this.hideDeleteModal();
|
||||
|
||||
this.disableForm();
|
||||
this.setFormStatusMessage('info', t('Deleting user...'));
|
||||
|
||||
await axios.delete(`/users/rest/users/${this.state.entityId}`);
|
||||
|
||||
this.navigateToWithFlashMessage('/users', 'success', t('User deleted'));
|
||||
}
|
||||
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
const edit = this.props.edit;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{edit &&
|
||||
<ModalDialog hidden={!this.isDelete()} title={t('Confirm deletion')} onCloseAsync={::this.hideDeleteModal} buttons={[
|
||||
{ label: t('No'), className: 'btn-primary', onClickAsync: ::this.hideDeleteModal },
|
||||
{ label: t('Yes'), className: 'btn-danger', onClickAsync: ::this.performDelete }
|
||||
]}>
|
||||
{t('Are you sure you want to delete user "{{username}}"?', {username: this.getFormValue('username')})}
|
||||
</ModalDialog>
|
||||
}
|
||||
|
||||
<Title>{edit ? t('Edit User') : t('Create User')}</Title>
|
||||
|
||||
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
|
||||
<InputField id="username" label={t('User Name')}/>
|
||||
<InputField id="name" label={t('Full Name')}/>
|
||||
<InputField id="email" label={t('Email')}/>
|
||||
<InputField id="password" label={t('Password')}/>
|
||||
<InputField id="password2" label={t('Repeat Password')}/>
|
||||
|
||||
<ButtonRow>
|
||||
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>
|
||||
{edit && <Button className="btn-danger" icon="remove" label={t('Delete User')}
|
||||
onClickAsync={::this.showDeleteModal}/>}
|
||||
</ButtonRow>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
38
client/src/users/List.js
Normal file
38
client/src/users/List.js
Normal file
|
@ -0,0 +1,38 @@
|
|||
'use strict';
|
||||
|
||||
import React, { Component } from 'react';
|
||||
import { translate } from 'react-i18next';
|
||||
import { Title, Toolbar, NavButton } from '../lib/page';
|
||||
import { Table, TableSelectMode } from '../lib/table';
|
||||
|
||||
@translate()
|
||||
export default class List extends Component {
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
|
||||
const actionLinks = [
|
||||
{
|
||||
label: 'Edit',
|
||||
link: data => '/users/edit/' + data[0]
|
||||
}
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{ data: 0, title: "#" },
|
||||
{ data: 1, title: "Username" },
|
||||
{ data: 2, title: "Full Name" }
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Toolbar>
|
||||
<NavButton linkTo="/users/create" className="btn-primary" icon="plus" label={t('Create User')}/>
|
||||
</Toolbar>
|
||||
|
||||
<Title>{t('Users')}</Title>
|
||||
|
||||
<Table withHeader dataUrl="/users/rest/usersTable" columns={columns} actionLinks={actionLinks} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
44
client/src/users/root.js
Normal file
44
client/src/users/root.js
Normal file
|
@ -0,0 +1,44 @@
|
|||
'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 CUD from './CUD'
|
||||
import List from './List'
|
||||
|
||||
const getStructure = t => ({
|
||||
'': {
|
||||
title: t('Home'),
|
||||
externalLink: '/',
|
||||
children: {
|
||||
'users': {
|
||||
title: t('Users'),
|
||||
link: '/users',
|
||||
component: List,
|
||||
children: {
|
||||
'edit' : {
|
||||
title: t('Edit User'),
|
||||
params: [':id', ':action?'],
|
||||
render: props => (<CUD edit {...props} />)
|
||||
},
|
||||
'create' : {
|
||||
title: t('Create User'),
|
||||
render: props => (<CUD {...props} />)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default function() {
|
||||
ReactDOM.render(
|
||||
<I18nextProvider i18n={ i18n }><Section root='/users' structure={getStructure}/></I18nextProvider>,
|
||||
document.getElementById('root')
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -3,7 +3,8 @@ const path = require('path');
|
|||
|
||||
module.exports = {
|
||||
entry: {
|
||||
namespaces: ['babel-polyfill', './src/namespaces/root.js']
|
||||
namespaces: ['babel-polyfill', './src/namespaces/root.js'],
|
||||
users: ['babel-polyfill', './src/users/root.js']
|
||||
},
|
||||
output: {
|
||||
library: 'MailtrainReactBody',
|
||||
|
@ -24,5 +25,9 @@ module.exports = {
|
|||
plugins: [
|
||||
// new webpack.optimize.UglifyJsPlugin(),
|
||||
new webpack.optimize.CommonsChunkPlugin('common')
|
||||
]
|
||||
],
|
||||
watchOptions: {
|
||||
ignored: 'node_modules/',
|
||||
poll: 1000
|
||||
}
|
||||
};
|
||||
|
|
54
lib/dt-helpers.js
Normal file
54
lib/dt-helpers.js
Normal file
|
@ -0,0 +1,54 @@
|
|||
'use strict';
|
||||
|
||||
const knex = require('../lib/knex');
|
||||
|
||||
async function ajaxList(params, queryFun, columns) {
|
||||
return await knex.transaction(async (tx) => {
|
||||
const query = queryFun(tx);
|
||||
|
||||
const recordsTotalQuery = query.clone().count('* as recordsTotal').first();
|
||||
const recordsTotal = (await recordsTotalQuery).recordsTotal;
|
||||
|
||||
query.where(function() {
|
||||
let searchVal = '%' + params.search.value.replace(/\\/g, '\\\\').replace(/([%_])/g, '\\$1') + '%';
|
||||
for (let colIdx = 0; colIdx < params.columns.length; colIdx++) {
|
||||
const col = params.columns[colIdx];
|
||||
if (col.searchable === 'true') {
|
||||
this.orWhere(columns[parseInt(col.data)], 'like', searchVal);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const recordsFilteredQuery = query.clone().count('* as recordsFiltered').first();
|
||||
const recordsFiltered = (await recordsFilteredQuery).recordsFiltered;
|
||||
|
||||
query.offset(parseInt(params.start));
|
||||
|
||||
const limit = parseInt(params.length);
|
||||
if (limit >= 0) {
|
||||
query.limit(limit);
|
||||
}
|
||||
|
||||
query.select(columns);
|
||||
|
||||
for (const order of params.order) {
|
||||
query.orderBy(columns[params.columns[order.column].data], order.dir);
|
||||
}
|
||||
|
||||
const rows = await query;
|
||||
const rowsOfArray = rows.map(row => Object.keys(row).map(key => row[key]));
|
||||
|
||||
const result = {
|
||||
draw: params.draw,
|
||||
recordsTotal,
|
||||
recordsFiltered,
|
||||
data: rowsOfArray
|
||||
};
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ajaxList
|
||||
};
|
|
@ -10,7 +10,7 @@ let LocalStrategy = require('passport-local').Strategy;
|
|||
|
||||
let csrf = require('csurf');
|
||||
let bodyParser = require('body-parser');
|
||||
let users = require('./models/users');
|
||||
let users = require('./models/users-legacy');
|
||||
|
||||
let LdapStrategy;
|
||||
try {
|
||||
|
|
41
lib/tools-async.js
Normal file
41
lib/tools-async.js
Normal file
|
@ -0,0 +1,41 @@
|
|||
'use strict';
|
||||
|
||||
const _ = require('./translate')._;
|
||||
const util = require('util');
|
||||
const Promise = require('bluebird');
|
||||
const isemail = require('isemail')
|
||||
|
||||
module.exports = {
|
||||
validateEmail
|
||||
};
|
||||
|
||||
async function validateEmail(address, checkBlocked) {
|
||||
let user = (address || '').toString().split('@').shift().toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
|
||||
if (checkBlocked && blockedUsers.indexOf(user) >= 0) {
|
||||
throw new new Error(util.format(_('Blocked email address "%s"'), address));
|
||||
}
|
||||
|
||||
const result = await new Promise(resolve => {
|
||||
const result = isemail.validate(address, {
|
||||
checkDNS: true,
|
||||
errorLevel: 1
|
||||
}, resolve);
|
||||
});
|
||||
|
||||
if (result !== 0) {
|
||||
let message = util.format(_('Invalid email address "%s".'), address);
|
||||
switch (result) {
|
||||
case 5:
|
||||
message += ' ' + _('MX record not found for domain');
|
||||
break;
|
||||
case 6:
|
||||
message += ' ' + _('Address domain not found');
|
||||
break;
|
||||
case 12:
|
||||
message += ' ' + _('Address domain name is required');
|
||||
break;
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
|
@ -1,12 +1,11 @@
|
|||
'use strict';
|
||||
|
||||
const knex = require('../knex');
|
||||
const knex = require('../lib/knex');
|
||||
const hasher = require('node-object-hash')();
|
||||
const { enforce, filterObject } = require('../helpers');
|
||||
const interoperableErrors = require('../../shared/interoperable-errors');
|
||||
const { enforce, filterObject } = require('../lib/helpers');
|
||||
const interoperableErrors = require('../shared/interoperable-errors');
|
||||
|
||||
const allowedKeys = new Set(['id', 'name', 'description', 'parent']);
|
||||
const allowedUpdateKeys = new Set(['name', 'description', 'parent']);
|
||||
const allowedKeys = new Set(['name', 'description', 'parent']);
|
||||
|
||||
async function list() {
|
||||
return await knex('namespaces');
|
||||
|
@ -54,7 +53,7 @@ async function updateWithConsistencyCheck(ns) {
|
|||
}
|
||||
}
|
||||
|
||||
await tx('namespaces').where('id', ns.id).update(filterObject(ns, allowedUpdateKeys));
|
||||
await tx('namespaces').where('id', ns.id).update(filterObject(ns, allowedKeys));
|
||||
});
|
||||
}
|
||||
|
107
models/users.js
Normal file
107
models/users.js
Normal file
|
@ -0,0 +1,107 @@
|
|||
'use strict';
|
||||
|
||||
const knex = require('../lib/knex');
|
||||
const hasher = require('node-object-hash')();
|
||||
const { enforce, filterObject } = require('../lib/helpers');
|
||||
const interoperableErrors = require('../shared/interoperable-errors');
|
||||
const passwordValidator = require('../shared/password-validator')();
|
||||
const dtHelpers = require('../lib/dt-helpers');
|
||||
const bcrypt = require('bcrypt-nodejs');
|
||||
const tools = require('../lib/tools');
|
||||
|
||||
const allowedKeys = new Set(['username', 'name', 'email', 'password']);
|
||||
|
||||
function hash(user) {
|
||||
return hasher.hash(filterObject(user, allowedKeys));
|
||||
}
|
||||
|
||||
async function getById(userId) {
|
||||
const user = await knex('users').select(['id', 'username', 'name', 'email']).where('id', userId).first();
|
||||
if (!user) {
|
||||
throw new interoperableErrors.NotFoundError();
|
||||
}
|
||||
|
||||
user.hash = hash(user);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async function getByUsername(username) {
|
||||
const user = await knex('users').select(['id', 'username', 'name', 'email']).where('username', username).first();
|
||||
if (!user) {
|
||||
throw new interoperableErrors.NotFoundError();
|
||||
}
|
||||
|
||||
user.hash = hash(user);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async function listDTAjax(params) {
|
||||
return await dtHelpers.ajaxList(params, tx => tx('users'), ['users.id', 'users.username', 'users.name']);
|
||||
}
|
||||
|
||||
async function create(user) {
|
||||
const userId = await knex('users').insert(filterObject(user, allowedKeys));
|
||||
return userId;
|
||||
}
|
||||
|
||||
async function updateWithConsistencyCheck(user) {
|
||||
await knex.transaction(async tx => {
|
||||
const existingUser = await tx('users').where('id', user.id).first();
|
||||
if (!user) {
|
||||
throw new interoperableErrors.NotFoundError();
|
||||
}
|
||||
|
||||
const existingUserHash = hash(existingUser);
|
||||
if (existingUserHash != user.originalHash) {
|
||||
throw new interoperableErrors.ChangedError();
|
||||
}
|
||||
|
||||
const otherUserWithSameUsername = await tx('users').whereNot('id', user.id).andWhere('username', user.username).first();
|
||||
if (otherUserWithSameUsername) {
|
||||
throw new interoperableErrors.DuplicitNameError();
|
||||
}
|
||||
|
||||
if (user.password) {
|
||||
const passwordValidatorResults = passwordValidator.test(user.password);
|
||||
if (passwordValidatorResults.errors.length > 0) {
|
||||
// This is not an interoperable error because this is not supposed to happen unless the client is tampered with.
|
||||
throw new Error('Invalid password');
|
||||
}
|
||||
|
||||
bcrypt.hash(updates.password, null, null, (err, hash) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
keys.push('password');
|
||||
values.push(hash);
|
||||
|
||||
finalize();
|
||||
});
|
||||
|
||||
tools.validateEmail(updates.email, false)
|
||||
|
||||
} else {
|
||||
delete user.password;
|
||||
}
|
||||
|
||||
await tx('users').where('id', user.id).update(filterObject(user, allowedKeys));
|
||||
});
|
||||
}
|
||||
|
||||
async function remove(userId) {
|
||||
// FIXME: enforce that userId is not the current user
|
||||
await knex('users').where('id', userId).del();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listDTAjax,
|
||||
remove,
|
||||
updateWithConsistencyCheck,
|
||||
create,
|
||||
hash,
|
||||
getById,
|
||||
getByUsername
|
||||
};
|
|
@ -103,6 +103,7 @@
|
|||
"npmlog": "^4.0.2",
|
||||
"object-hash": "^1.1.7",
|
||||
"openpgp": "^2.5.3",
|
||||
"owasp-password-strength-test": "github:bures/owasp-password-strength-test",
|
||||
"passport": "^0.3.2",
|
||||
"passport-local": "^1.0.0",
|
||||
"premailer-api": "^1.0.4",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
'use strict';
|
||||
|
||||
let users = require('../lib/models/users');
|
||||
let users = require('../lib/models/users-legacy');
|
||||
let lists = require('../lib/models/lists');
|
||||
let fields = require('../lib/models/fields');
|
||||
let blacklist = require('../lib/models/blacklist');
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
const passport = require('../lib/passport');
|
||||
const router = require('../lib/router-async').create();
|
||||
const _ = require('../lib/translate')._;
|
||||
const namespaces = require('../lib/models/namespaces');
|
||||
const namespaces = require('../models/namespaces');
|
||||
const interoperableErrors = require('../shared/interoperable-errors');
|
||||
|
||||
router.all('/rest/*', (req, res, next) => {
|
||||
|
|
147
routes/users-legacy.js
Normal file
147
routes/users-legacy.js
Normal file
|
@ -0,0 +1,147 @@
|
|||
'use strict';
|
||||
|
||||
let passport = require('../lib/passport');
|
||||
let express = require('express');
|
||||
let router = new express.Router();
|
||||
let users = require('../lib/models/users-legacy');
|
||||
let fields = require('../lib/models/fields');
|
||||
let settings = require('../lib/models/settings');
|
||||
let _ = require('../lib/translate')._;
|
||||
|
||||
router.get('/logout', (req, res) => passport.logout(req, res));
|
||||
|
||||
router.post('/login', passport.parseForm, (req, res, next) => passport.login(req, res, next));
|
||||
router.get('/login', (req, res) => {
|
||||
res.render('users/login', {
|
||||
next: req.query.next
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/forgot', passport.csrfProtection, (req, res) => {
|
||||
res.render('users/forgot', {
|
||||
csrfToken: req.csrfToken()
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/forgot', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||
users.sendReset(req.body.username, err => {
|
||||
if (err) {
|
||||
req.flash('danger', err.message || err);
|
||||
return res.redirect('/users/forgot');
|
||||
} else {
|
||||
req.flash('success', _('An email with password reset instructions has been sent to your email address, if it exists on our system.'));
|
||||
}
|
||||
return res.redirect('/users/login');
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/reset', passport.csrfProtection, (req, res) => {
|
||||
users.checkResetToken(req.query.username, req.query.token, (err, status) => {
|
||||
if (err) {
|
||||
req.flash('danger', err.message || err);
|
||||
return res.redirect('/users/login');
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
req.flash('danger', _('Unknown or expired reset token'));
|
||||
return res.redirect('/users/login');
|
||||
}
|
||||
|
||||
res.render('users/reset', {
|
||||
csrfToken: req.csrfToken(),
|
||||
username: req.query.username,
|
||||
resetToken: req.query.token
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/reset', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||
users.resetPassword(req.body, (err, status) => {
|
||||
if (err) {
|
||||
req.flash('danger', err.message || err);
|
||||
return res.redirect('/users/reset?username=' + encodeURIComponent(req.body.username) + '&token=' + encodeURIComponent(req.body['reset-token']));
|
||||
} else if (!status) {
|
||||
req.flash('danger', _('Unknown or expired reset token'));
|
||||
} else {
|
||||
req.flash('success', _('Your password has been changed successfully'));
|
||||
}
|
||||
|
||||
return res.redirect('/users/login');
|
||||
});
|
||||
});
|
||||
|
||||
router.all('/api', (req, res, next) => {
|
||||
if (!req.user) {
|
||||
req.flash('danger', _('Need to be logged in to access restricted content'));
|
||||
return res.redirect('/users/login?next=' + encodeURIComponent(req.originalUrl));
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
router.get('/api', passport.csrfProtection, (req, res, next) => {
|
||||
users.get(req.user.id, (err, user) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
if (!user) {
|
||||
return next(new Error(_('User data not found')));
|
||||
}
|
||||
settings.list(['serviceUrl'], (err, configItems) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
user.serviceUrl = configItems.serviceUrl;
|
||||
user.csrfToken = req.csrfToken();
|
||||
user.allowedTypes = Object.keys(fields.types).map(key => ({
|
||||
type: key,
|
||||
description: fields.types[key]
|
||||
}));
|
||||
res.render('users/api', user);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
router.post('/api/reset-token', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||
users.resetToken(Number(req.user.id), (err, success) => {
|
||||
if (err) {
|
||||
req.flash('danger', err.message || err);
|
||||
} else if (success) {
|
||||
req.flash('success', _('Access token updated'));
|
||||
} else {
|
||||
req.flash('info', _('Access token not updated'));
|
||||
}
|
||||
return res.redirect('/users/api');
|
||||
});
|
||||
});
|
||||
|
||||
router.all('/account', (req, res, next) => {
|
||||
if (!req.user) {
|
||||
req.flash('danger', _('Need to be logged in to access restricted content'));
|
||||
return res.redirect('/users/login?next=' + encodeURIComponent(req.originalUrl));
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
router.get('/account', passport.csrfProtection, (req, res) => {
|
||||
let data = {
|
||||
csrfToken: req.csrfToken(),
|
||||
email: req.user.email
|
||||
};
|
||||
res.render('users/account', data);
|
||||
});
|
||||
|
||||
router.post('/account', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||
users.update(Number(req.user.id), req.body, (err, success) => {
|
||||
if (err) {
|
||||
req.flash('danger', err.message || err);
|
||||
} else if (success) {
|
||||
req.flash('success', _('Account information updated'));
|
||||
} else {
|
||||
req.flash('info', _('Account information not updated'));
|
||||
}
|
||||
return res.redirect('/users/account');
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
199
routes/users.js
199
routes/users.js
|
@ -1,147 +1,102 @@
|
|||
'use strict';
|
||||
|
||||
let passport = require('../lib/passport');
|
||||
let express = require('express');
|
||||
let router = new express.Router();
|
||||
let users = require('../lib/models/users');
|
||||
let fields = require('../lib/models/fields');
|
||||
let settings = require('../lib/models/settings');
|
||||
let _ = require('../lib/translate')._;
|
||||
const passport = require('../lib/passport');
|
||||
const router = require('../lib/router-async').create();
|
||||
const _ = require('../lib/translate')._;
|
||||
const users = require('../models/users');
|
||||
const interoperableErrors = require('../shared/interoperable-errors');
|
||||
const tools = require('../lib/tools-async');
|
||||
|
||||
router.get('/logout', (req, res) => passport.logout(req, res));
|
||||
|
||||
router.post('/login', passport.parseForm, (req, res, next) => passport.login(req, res, next));
|
||||
router.get('/login', (req, res) => {
|
||||
res.render('users/login', {
|
||||
next: req.query.next
|
||||
});
|
||||
});
|
||||
router.all('/rest/*', (req, res, next) => {
|
||||
req.needsJSONResponse = true;
|
||||
|
||||
router.get('/forgot', passport.csrfProtection, (req, res) => {
|
||||
res.render('users/forgot', {
|
||||
csrfToken: req.csrfToken()
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/forgot', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||
users.sendReset(req.body.username, err => {
|
||||
if (err) {
|
||||
req.flash('danger', err.message || err);
|
||||
return res.redirect('/users/forgot');
|
||||
} else {
|
||||
req.flash('success', _('An email with password reset instructions has been sent to your email address, if it exists on our system.'));
|
||||
}
|
||||
return res.redirect('/users/login');
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/reset', passport.csrfProtection, (req, res) => {
|
||||
users.checkResetToken(req.query.username, req.query.token, (err, status) => {
|
||||
if (err) {
|
||||
req.flash('danger', err.message || err);
|
||||
return res.redirect('/users/login');
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
req.flash('danger', _('Unknown or expired reset token'));
|
||||
return res.redirect('/users/login');
|
||||
}
|
||||
|
||||
res.render('users/reset', {
|
||||
csrfToken: req.csrfToken(),
|
||||
username: req.query.username,
|
||||
resetToken: req.query.token
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/reset', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||
users.resetPassword(req.body, (err, status) => {
|
||||
if (err) {
|
||||
req.flash('danger', err.message || err);
|
||||
return res.redirect('/users/reset?username=' + encodeURIComponent(req.body.username) + '&token=' + encodeURIComponent(req.body['reset-token']));
|
||||
} else if (!status) {
|
||||
req.flash('danger', _('Unknown or expired reset token'));
|
||||
} else {
|
||||
req.flash('success', _('Your password has been changed successfully'));
|
||||
}
|
||||
|
||||
return res.redirect('/users/login');
|
||||
});
|
||||
});
|
||||
|
||||
router.all('/api', (req, res, next) => {
|
||||
if (!req.user) {
|
||||
req.flash('danger', _('Need to be logged in to access restricted content'));
|
||||
return res.redirect('/users/login?next=' + encodeURIComponent(req.originalUrl));
|
||||
throw new interoperableErrors.NotLoggedInError();
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
router.get('/api', passport.csrfProtection, (req, res, next) => {
|
||||
users.get(req.user.id, (err, user) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
if (!user) {
|
||||
return next(new Error(_('User data not found')));
|
||||
}
|
||||
settings.list(['serviceUrl'], (err, configItems) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
router.getAsync('/rest/users/:userId', async (req, res) => {
|
||||
const user = await users.getById(req.params.userId);
|
||||
return res.json(user);
|
||||
});
|
||||
|
||||
router.postAsync('/rest/users', passport.csrfProtection, async (req, res) => {
|
||||
await users.create(req.body);
|
||||
return res.json();
|
||||
});
|
||||
|
||||
router.putAsync('/rest/users/:userId', passport.csrfProtection, async (req, res) => {
|
||||
const user = req.body;
|
||||
user.id = parseInt(req.params.userId);
|
||||
|
||||
await users.updateWithConsistencyCheck(user);
|
||||
return res.json();
|
||||
});
|
||||
|
||||
router.deleteAsync('/rest/users/:userId', passport.csrfProtection, async (req, res) => {
|
||||
await users.remove(req.params.userId);
|
||||
return res.json();
|
||||
});
|
||||
|
||||
router.postAsync('/rest/validate', async (req, res) => {
|
||||
const data = {};
|
||||
|
||||
if (req.body.username) {
|
||||
data.username = {};
|
||||
|
||||
try {
|
||||
await users.getByUsername(req.body.username);
|
||||
data.username.exists = true;
|
||||
} catch (error) {
|
||||
if (error instanceof interoperableErrors.NotFoundError) {
|
||||
data.username.exists = false;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
user.serviceUrl = configItems.serviceUrl;
|
||||
user.csrfToken = req.csrfToken();
|
||||
user.allowedTypes = Object.keys(fields.types).map(key => ({
|
||||
type: key,
|
||||
description: fields.types[key]
|
||||
}));
|
||||
res.render('users/api', user);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
router.post('/api/reset-token', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||
users.resetToken(Number(req.user.id), (err, success) => {
|
||||
if (err) {
|
||||
req.flash('danger', err.message || err);
|
||||
} else if (success) {
|
||||
req.flash('success', _('Access token updated'));
|
||||
} else {
|
||||
req.flash('info', _('Access token not updated'));
|
||||
}
|
||||
return res.redirect('/users/api');
|
||||
});
|
||||
}
|
||||
|
||||
if (req.body.email) {
|
||||
data.email = {};
|
||||
|
||||
try {
|
||||
await tools.validateEmail(req.body.email);
|
||||
data.email.invalid = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
data.email.invalid = true;
|
||||
}
|
||||
}
|
||||
|
||||
return res.json(data);
|
||||
});
|
||||
|
||||
router.all('/account', (req, res, next) => {
|
||||
|
||||
router.postAsync('/rest/usersTable', async (req, res) => {
|
||||
return res.json(await users.listDTAjax(req.body));
|
||||
});
|
||||
|
||||
|
||||
|
||||
router.all('/*', (req, res, next) => {
|
||||
if (!req.user) {
|
||||
req.flash('danger', _('Need to be logged in to access restricted content'));
|
||||
return res.redirect('/users/login?next=' + encodeURIComponent(req.originalUrl));
|
||||
}
|
||||
// res.setSelectedMenu('users'); FIXME
|
||||
next();
|
||||
});
|
||||
|
||||
router.get('/account', passport.csrfProtection, (req, res) => {
|
||||
let data = {
|
||||
csrfToken: req.csrfToken(),
|
||||
email: req.user.email
|
||||
};
|
||||
res.render('users/account', data);
|
||||
});
|
||||
|
||||
router.post('/account', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||
users.update(Number(req.user.id), req.body, (err, success) => {
|
||||
if (err) {
|
||||
req.flash('danger', err.message || err);
|
||||
} else if (success) {
|
||||
req.flash('success', _('Account information updated'));
|
||||
} else {
|
||||
req.flash('info', _('Account information not updated'));
|
||||
}
|
||||
return res.redirect('/users/account');
|
||||
router.getAsync('/*', passport.csrfProtection, async (req, res) => {
|
||||
res.render('react-root', {
|
||||
title: _('Users'),
|
||||
reactEntryPoint: 'users',
|
||||
reactCsrfToken: req.csrfToken()
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
module.exports = router;
|
||||
|
|
8
setup/knex/migrations/20170617123450_create_user_name.js
Normal file
8
setup/knex/migrations/20170617123450_create_user_name.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
exports.up = function(knex, Promise) {
|
||||
return knex.schema.table('users', table => {
|
||||
table.string('name');
|
||||
})
|
||||
};
|
||||
|
||||
exports.down = function(knex, Promise) {
|
||||
};
|
|
@ -38,6 +38,11 @@ class ChildDetectedError extends InteroperableError {
|
|||
}
|
||||
}
|
||||
|
||||
class DuplicitNameError extends InteroperableError {
|
||||
constructor(msg, data) {
|
||||
super('DuplicitNameError', msg, data);
|
||||
}
|
||||
}
|
||||
|
||||
const errorTypes = {
|
||||
InteroperableError,
|
||||
|
@ -45,7 +50,8 @@ const errorTypes = {
|
|||
ChangedError,
|
||||
NotFoundError,
|
||||
LoopDetectedError,
|
||||
ChildDetectedError
|
||||
ChildDetectedError,
|
||||
DuplicitNameError
|
||||
};
|
||||
|
||||
function deserialize(errorObj) {
|
||||
|
|
37
shared/password-validator.js
Normal file
37
shared/password-validator.js
Normal file
|
@ -0,0 +1,37 @@
|
|||
'use strict';
|
||||
|
||||
const util = require('util');
|
||||
const owaspPasswordStrengthTest = require('owasp-password-strength-test');
|
||||
|
||||
function passwordValidator(t) {
|
||||
const config = {
|
||||
allowPassphrases: true,
|
||||
maxLength: 128,
|
||||
minLength: 10,
|
||||
minPhraseLength: 20,
|
||||
minOptionalTestsToPass: 4
|
||||
};
|
||||
|
||||
if (t) {
|
||||
config.translate = {
|
||||
minLength: function (minLength) {
|
||||
return t('The password must be at least {{ minLength }} characters long', { minLength });
|
||||
},
|
||||
maxLength: function (maxLength) {
|
||||
return t('The password must be fewer than {{ maxLength }} characters', { maxLength });
|
||||
},
|
||||
repeat: t('The password may not contain sequences of three or more repeated characters'),
|
||||
lowercase: t('The password must contain at least one lowercase letter'),
|
||||
uppercase: t('The password must contain at least one uppercase letter'),
|
||||
number: t('The password must contain at least one number'),
|
||||
special: t('The password must contain at least one special character')
|
||||
}
|
||||
}
|
||||
|
||||
const passwordValidator = owaspPasswordStrengthTest.create();
|
||||
passwordValidator.config(config);
|
||||
|
||||
return passwordValidator;
|
||||
}
|
||||
|
||||
module.exports = passwordValidator;
|
Loading…
Reference in a new issue