diff --git a/client/src/lib/form.js b/client/src/lib/form.js
index a411081d..916e2adf 100644
--- a/client/src/lib/form.js
+++ b/client/src/lib/form.js
@@ -1,557 +1,574 @@
-'use strict';
-
-import React, { Component } from 'react';
-import axios from './axios';
-import Immutable from 'immutable';
-import { translate } from 'react-i18next';
-import PropTypes from 'prop-types';
-import interoperableErrors from '../../../shared/interoperable-errors';
-import { withPageHelpers } from './page'
-import { withErrorHandling, withAsyncErrorHandler } from './error-handling';
-import { TreeTable, TreeSelectMode } from './tree';
-
-const FormState = {
- Loading: 0,
- LoadingWithNotice: 1,
- Ready: 2
-};
-
-const FormSendMethod = {
- PUT: 0,
- POST: 1
-};
-
-@translate()
-@withPageHelpers
-@withErrorHandling
-class Form extends Component {
- static propTypes = {
- stateOwner: PropTypes.object.isRequired,
- onSubmitAsync: PropTypes.func
- }
-
- static childContextTypes = {
- formStateOwner: PropTypes.object
- }
-
- getChildContext() {
- return {
- formStateOwner: this.props.stateOwner
- };
- }
-
- @withAsyncErrorHandler
- async onSubmit(evt) {
- const t = this.props.t;
-
- const owner = this.props.stateOwner;
-
- try {
- evt.preventDefault();
-
- if (this.props.onSubmitAsync) {
- await this.props.onSubmitAsync(evt);
- }
- } catch (error) {
- if (error instanceof interoperableErrors.ChangedError) {
- owner.disableForm();
- owner.setFormStatusMessage('danger',
-
- {t('Your updates cannot be saved.')}{' '}
- {t('Someone else has introduced modification in the meantime. Refresh your page to start anew with fresh data. Please note that your changes will be lost.')}
-
- );
- return;
- }
-
- throw error;
- }
- }
-
- render() {
- const t = this.props.t;
- const owner = this.props.stateOwner;
- const props = this.props;
- const statusMessageText = owner.getFormStatusMessageText();
- const statusMessageSeverity = owner.getFormStatusMessageSeverity();
-
- if (!owner.isFormReady()) {
- if (owner.isFormWithLoadingNotice()) {
- return
{t('Loading ...')}
- } else {
- return ;
- }
- } else {
- return (
-
- );
- }
- }
-}
-
-function wrapInput(id, htmlId, owner, label, input) {
- return (
-
-
-
-
-
- {input}
-
-
{owner.getFormValidationMessage(id)}
-
- );
-}
-
-class InputField extends Component {
- static propTypes = {
- id: PropTypes.string.isRequired,
- label: PropTypes.string.isRequired,
- placeholder: PropTypes.string
- }
-
- static contextTypes = {
- formStateOwner: PropTypes.object.isRequired
- }
-
- render() {
- const props = this.props;
- const owner = this.context.formStateOwner;
- const id = this.props.id;
- const htmlId = 'form_' + id;
-
- return wrapInput(id, htmlId, owner, props.label,
- owner.updateFormValue(id, evt.target.value)}/>
- );
- }
-}
-
-class TextArea extends Component {
- static propTypes = {
- id: PropTypes.string.isRequired,
- label: PropTypes.string.isRequired,
- placeholder: PropTypes.string
- }
-
- static contextTypes = {
- formStateOwner: PropTypes.object.isRequired
- }
-
- render() {
- const props = this.props;
- const owner = this.context.formStateOwner;
- const id = this.props.id;
- const htmlId = 'form_' + id;
-
- return wrapInput(id, htmlId, owner, props.label,
-
- );
- }
-}
-
-class ButtonRow extends Component {
- render() {
- return (
-
-
- {this.props.children}
-
-
- );
- }
-}
-
-@withErrorHandling
-class Button extends Component {
- static propTypes = {
- onClickAsync: PropTypes.func,
- onClick: PropTypes.func,
- label: PropTypes.string,
- icon: PropTypes.string,
- className: PropTypes.string,
- type: PropTypes.string
- }
-
- static contextTypes = {
- formStateOwner: PropTypes.object.isRequired
- }
-
- @withAsyncErrorHandler
- async onClick(evt) {
- if (this.props.onClick) {
- evt.preventDefault();
-
- onClick(evt);
-
- } else if (this.props.onClickAsync) {
- evt.preventDefault();
-
- this.context.formStateOwner.disableForm();
- await this.props.onClickAsync(evt);
- this.context.formStateOwner.enableForm();
- }
- }
-
- render() {
- const props = this.props;
-
- let className = 'btn';
- if (props.className) {
- className = className + ' ' + props.className;
- }
-
- let type = props.type || 'button';
-
- let icon;
- if (props.icon) {
- icon =
- }
-
- let iconSpacer;
- if (props.icon && props.label) {
- iconSpacer = ' ';
- }
-
- return (
-
- );
- }
-}
-
-class TreeTableSelect extends Component {
- static propTypes = {
- id: PropTypes.string.isRequired,
- label: PropTypes.string.isRequired,
- dataUrl: PropTypes.string,
- data: PropTypes.array
- }
-
- static contextTypes = {
- formStateOwner: PropTypes.object.isRequired
- }
-
- async onSelectionChangedAsync(sel) {
- const owner = this.context.formStateOwner;
- owner.updateFormValue(this.props.id, sel);
- }
-
- render() {
- const props = this.props;
- const owner = this.context.formStateOwner;
- const id = this.props.id;
- const htmlId = 'form_' + id;
-
- return (
-
-
-
-
-
-
-
-
{owner.getFormValidationMessage(id)}
-
- );
- }
-}
-
-function withForm(target) {
- const inst = target.prototype;
-
- const cleanFormState = Immutable.Map({
- state: FormState.Loading,
- isValidationShown: false,
- isDisabled: false,
- statusMessageText: '',
- data: Immutable.Map(),
- isServerValidationRunning: false
- });
-
- inst.initFormState = function(serverValidationUrl, serverValidationAttrs) {
- const state = this.state || {};
- state.formState = cleanFormState;
- if (serverValidationUrl) {
- state.formStateServerValidation = { url: serverValidationUrl, attrs: serverValidationAttrs };
- }
- this.state = state;
- };
-
- inst.resetFormState = function() {
- this.setState({
- formState: cleanFormState
- });
- };
-
- inst.getFormValuesFromURL = async function(url, mutator) {
- setTimeout(() => {
- this.setState(previousState => {
- if (previousState.formState.get('state') === FormState.Loading) {
- return {
- formState: previousState.formState.set('state', FormState.LoadingWithNotice)
- };
- }
- });
- }, 500);
-
- const response = await axios.get(url);
-
- const data = response.data;
-
- data.originalHash = data.hash;
- delete data.hash;
-
- if (mutator) {
- mutator(data);
- }
-
- this.populateFormValues(data);
- };
-
- inst.validateAndSendFormValuesToURL = async function(method, url, mutator) {
- await this.waitForFormServerValidated();
-
- if (this.isFormWithoutErrors()) {
- const data = this.getFormValues();
-
- if (mutator) {
- mutator(data);
- }
-
- if (method === FormSendMethod.PUT) {
- await axios.put(url, data);
- } else if (method === FormSendMethod.POST) {
- await axios.post(url, data);
- }
- return true;
- } else {
- this.showFormValidation();
- return false;
- }
- };
-
-
- inst.populateFormValues = function(data) {
- this.setState(previousState => ({
- formState: previousState.formState.withMutations(mutState => {
- mutState.set('state', FormState.Ready);
-
- mutState.update('data', stateData => stateData.withMutations(mutStateData => {
- for (const key in data) {
- mutStateData.set(key, Immutable.Map({
- value: data[key]
- }));
- }
- }));
-
- 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.withMutations(mutState => {
- mutState.setIn(['data', key, 'value'], value);
- this.validateForm(mutState);
- })
- }));
- };
-
- inst.getFormValue = function(name) {
- return this.state.formState.getIn(['data', name, 'value']);
- };
-
- inst.getFormValues = function(name) {
- return this.state.formState.get('data').map(attr => attr.get('value')).toJS();
- };
-
- inst.getFormError = function(name) {
- return this.state.formState.getIn(['data', name, 'error']);
- };
-
- inst.isFormWithLoadingNotice = function() {
- return this.state.formState.get('state') === FormState.LoadingWithNotice;
- };
-
- inst.isFormLoading = function() {
- return this.state.formState.get('state') === FormState.Loading || this.state.formState.get('state') === FormState.LoadingWithNotice;
- };
-
- inst.isFormReady = function() {
- return this.state.formState.get('state') === FormState.Ready;
- };
-
- inst.isFormValidationShown = function() {
- return this.state.formState.get('isValidationShown');
- };
-
- inst.addFormValidationClass = function(className, name) {
- if (this.isFormValidationShown()) {
- const error = this.getFormError(name);
- if (error) {
- return className + ' has-error';
- } else {
- return className + ' has-success';
- }
- } else {
- return className;
- }
- };
-
- inst.getFormValidationMessage = function(name) {
- if (this.isFormValidationShown()) {
- return this.getFormError(name);
- } else {
- return '';
- }
- };
-
- inst.showFormValidation = function() {
- this.setState(previousState => ({formState: previousState.formState.set('isValidationShown', true)}));
- };
-
- inst.hideFormValidation = function() {
- this.setState(previousState => ({formState: previousState.formState.set('isValidationShown', false)}));
- };
-
- inst.isFormWithoutErrors = function() {
- 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');
- };
-
- inst.getFormStatusMessageSeverity = function() {
- return this.state.formState.get('statusMessageSeverity');
- };
-
- inst.setFormStatusMessage = function(severity, text) {
- this.setState(previousState => ({
- formState: previousState.formState.withMutations(map => {
- map.set('statusMessageText', text);
- map.set('statusMessageSeverity', severity);
- })
- }));
- };
-
- inst.enableForm = function() {
- this.setState(previousState => ({formState: previousState.formState.set('isDisabled', false)}));
- };
-
- inst.disableForm = function() {
- this.setState(previousState => ({formState: previousState.formState.set('isDisabled', true)}));
- };
-
- inst.isFormDisabled = function() {
- return this.state.formState.get('isDisabled');
- };
-
- return target;
-}
-
-
-export {
- withForm,
- Form,
- InputField,
- TextArea,
- ButtonRow,
- Button,
- TreeTableSelect,
- FormSendMethod
-}
+'use strict';
+
+import React, { Component } from 'react';
+import axios from './axios';
+import Immutable from 'immutable';
+import { translate } from 'react-i18next';
+import PropTypes from 'prop-types';
+import interoperableErrors from '../../../shared/interoperable-errors';
+import { withPageHelpers } from './page'
+import { withErrorHandling, withAsyncErrorHandler } from './error-handling';
+import { TreeTable, TreeSelectMode } from './tree';
+
+const FormState = {
+ Loading: 0,
+ LoadingWithNotice: 1,
+ Ready: 2
+};
+
+const FormSendMethod = {
+ PUT: 0,
+ POST: 1
+};
+
+@translate()
+@withPageHelpers
+@withErrorHandling
+class Form extends Component {
+ static propTypes = {
+ stateOwner: PropTypes.object.isRequired,
+ onSubmitAsync: PropTypes.func
+ }
+
+ static childContextTypes = {
+ formStateOwner: PropTypes.object
+ }
+
+ getChildContext() {
+ return {
+ formStateOwner: this.props.stateOwner
+ };
+ }
+
+ @withAsyncErrorHandler
+ async onSubmit(evt) {
+ const t = this.props.t;
+
+ const owner = this.props.stateOwner;
+
+ try {
+ evt.preventDefault();
+
+ if (this.props.onSubmitAsync) {
+ await this.props.onSubmitAsync(evt);
+ }
+ } catch (error) {
+ if (error instanceof interoperableErrors.ChangedError) {
+ owner.disableForm();
+ owner.setFormStatusMessage('danger',
+
+ {t('Your updates cannot be saved.')}{' '}
+ {t('Someone else has introduced modification in the meantime. Refresh your page to start anew with fresh data. Please note that your changes will be lost.')}
+
+ );
+ return;
+ }
+
+ throw error;
+ }
+ }
+
+ render() {
+ const t = this.props.t;
+ const owner = this.props.stateOwner;
+ const props = this.props;
+ const statusMessageText = owner.getFormStatusMessageText();
+ const statusMessageSeverity = owner.getFormStatusMessageSeverity();
+
+ if (!owner.isFormReady()) {
+ if (owner.isFormWithLoadingNotice()) {
+ return {t('Loading ...')}
+ } else {
+ return ;
+ }
+ } else {
+ return (
+
+ );
+ }
+ }
+}
+
+function wrapInput(id, htmlId, owner, label, input) {
+ return (
+
+
+
+
+
+ {input}
+
+
{owner.getFormValidationMessage(id)}
+
+ );
+}
+
+class InputField extends Component {
+ static propTypes = {
+ id: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ placeholder: PropTypes.string,
+ type: PropTypes.string
+ }
+
+ static defaultProps = {
+ type: 'text'
+ }
+
+ static contextTypes = {
+ formStateOwner: PropTypes.object.isRequired
+ }
+
+ render() {
+ const props = this.props;
+ const owner = this.context.formStateOwner;
+ const id = this.props.id;
+ const htmlId = 'form_' + id;
+
+ let type = 'text';
+ if (props.type === 'password') {
+ type = 'password';
+ }
+
+ return wrapInput(id, htmlId, owner, props.label,
+ owner.updateFormValue(id, evt.target.value)}/>
+ );
+ }
+}
+
+class TextArea extends Component {
+ static propTypes = {
+ id: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ placeholder: PropTypes.string
+ }
+
+ static contextTypes = {
+ formStateOwner: PropTypes.object.isRequired
+ }
+
+ render() {
+ const props = this.props;
+ const owner = this.context.formStateOwner;
+ const id = this.props.id;
+ const htmlId = 'form_' + id;
+
+ return wrapInput(id, htmlId, owner, props.label,
+
+ );
+ }
+}
+
+class ButtonRow extends Component {
+ render() {
+ return (
+
+
+ {this.props.children}
+
+
+ );
+ }
+}
+
+@withErrorHandling
+class Button extends Component {
+ static propTypes = {
+ onClickAsync: PropTypes.func,
+ onClick: PropTypes.func,
+ label: PropTypes.string,
+ icon: PropTypes.string,
+ className: PropTypes.string,
+ type: PropTypes.string
+ }
+
+ static contextTypes = {
+ formStateOwner: PropTypes.object.isRequired
+ }
+
+ @withAsyncErrorHandler
+ async onClick(evt) {
+ if (this.props.onClick) {
+ evt.preventDefault();
+
+ onClick(evt);
+
+ } else if (this.props.onClickAsync) {
+ evt.preventDefault();
+
+ this.context.formStateOwner.disableForm();
+ await this.props.onClickAsync(evt);
+ this.context.formStateOwner.enableForm();
+ }
+ }
+
+ render() {
+ const props = this.props;
+
+ let className = 'btn';
+ if (props.className) {
+ className = className + ' ' + props.className;
+ }
+
+ let type = props.type || 'button';
+
+ let icon;
+ if (props.icon) {
+ icon =
+ }
+
+ let iconSpacer;
+ if (props.icon && props.label) {
+ iconSpacer = ' ';
+ }
+
+ return (
+
+ );
+ }
+}
+
+class TreeTableSelect extends Component {
+ static propTypes = {
+ id: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ dataUrl: PropTypes.string,
+ data: PropTypes.array
+ }
+
+ static contextTypes = {
+ formStateOwner: PropTypes.object.isRequired
+ }
+
+ async onSelectionChangedAsync(sel) {
+ const owner = this.context.formStateOwner;
+ owner.updateFormValue(this.props.id, sel);
+ }
+
+ render() {
+ const props = this.props;
+ const owner = this.context.formStateOwner;
+ const id = this.props.id;
+ const htmlId = 'form_' + id;
+
+ return (
+
+
+
+
+
+
+
+
{owner.getFormValidationMessage(id)}
+
+ );
+ }
+}
+
+function withForm(target) {
+ const inst = target.prototype;
+
+ const cleanFormState = Immutable.Map({
+ state: FormState.Loading,
+ isValidationShown: false,
+ isDisabled: false,
+ statusMessageText: '',
+ data: Immutable.Map(),
+ isServerValidationRunning: false
+ });
+
+ inst.initForm = function(settings) {
+ const state = this.state || {};
+ state.formState = cleanFormState;
+ state.formSettings = settings;
+ this.state = state;
+ };
+
+ inst.resetFormState = function() {
+ this.setState({
+ formState: cleanFormState
+ });
+ };
+
+ inst.getFormValuesFromURL = async function(url, mutator) {
+ setTimeout(() => {
+ this.setState(previousState => {
+ if (previousState.formState.get('state') === FormState.Loading) {
+ return {
+ formState: previousState.formState.set('state', FormState.LoadingWithNotice)
+ };
+ }
+ });
+ }, 500);
+
+ const response = await axios.get(url);
+
+ const data = response.data;
+
+ data.originalHash = data.hash;
+ delete data.hash;
+
+ if (mutator) {
+ mutator(data);
+ }
+
+ this.populateFormValues(data);
+ };
+
+ inst.validateAndSendFormValuesToURL = async function(method, url, mutator) {
+ await this.waitForFormServerValidated();
+
+ if (this.isFormWithoutErrors()) {
+ const data = this.getFormValues();
+
+ if (mutator) {
+ mutator(data);
+ }
+
+ if (method === FormSendMethod.PUT) {
+ await axios.put(url, data);
+ } else if (method === FormSendMethod.POST) {
+ await axios.post(url, data);
+ }
+ return true;
+ } else {
+ this.showFormValidation();
+ return false;
+ }
+ };
+
+
+ inst.populateFormValues = function(data) {
+ this.setState(previousState => ({
+ formState: previousState.formState.withMutations(mutState => {
+ mutState.set('state', FormState.Ready);
+
+ mutState.update('data', stateData => stateData.withMutations(mutStateData => {
+ for (const key in data) {
+ mutStateData.set(key, Immutable.Map({
+ value: data[key]
+ }));
+ }
+ }));
+
+ 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 settings = this.state.formSettings;
+
+ if (!mutState.get('isServerValidationRunning') && settings.serverValidation) {
+ const payload = {};
+ let payloadNotEmpty = false;
+
+ for (const attr of settings.serverValidation.extra || []) {
+ payload[attr] = mutState.getIn(['data', attr, 'value']);
+ }
+
+ for (const attr of settings.serverValidation.changed) {
+ 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(settings.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);
+
+ this.setState(previousState => ({
+ formState: previousState.formState.set('isServerValidationRunning', false)
+ }));
+
+ 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.withMutations(mutState => {
+ mutState.setIn(['data', key, 'value'], value);
+ this.validateForm(mutState);
+ })
+ }));
+ };
+
+ inst.getFormValue = function(name) {
+ return this.state.formState.getIn(['data', name, 'value']);
+ };
+
+ inst.getFormValues = function(name) {
+ return this.state.formState.get('data').map(attr => attr.get('value')).toJS();
+ };
+
+ inst.getFormError = function(name) {
+ return this.state.formState.getIn(['data', name, 'error']);
+ };
+
+ inst.isFormWithLoadingNotice = function() {
+ return this.state.formState.get('state') === FormState.LoadingWithNotice;
+ };
+
+ inst.isFormLoading = function() {
+ return this.state.formState.get('state') === FormState.Loading || this.state.formState.get('state') === FormState.LoadingWithNotice;
+ };
+
+ inst.isFormReady = function() {
+ return this.state.formState.get('state') === FormState.Ready;
+ };
+
+ inst.isFormValidationShown = function() {
+ return this.state.formState.get('isValidationShown');
+ };
+
+ inst.addFormValidationClass = function(className, name) {
+ if (this.isFormValidationShown()) {
+ const error = this.getFormError(name);
+ if (error) {
+ return className + ' has-error';
+ } else {
+ return className + ' has-success';
+ }
+ } else {
+ return className;
+ }
+ };
+
+ inst.getFormValidationMessage = function(name) {
+ if (this.isFormValidationShown()) {
+ return this.getFormError(name);
+ } else {
+ return '';
+ }
+ };
+
+ inst.showFormValidation = function() {
+ this.setState(previousState => ({formState: previousState.formState.set('isValidationShown', true)}));
+ };
+
+ inst.hideFormValidation = function() {
+ this.setState(previousState => ({formState: previousState.formState.set('isValidationShown', false)}));
+ };
+
+ inst.isFormWithoutErrors = function() {
+ return !this.state.formState.get('data').find(attr => attr.get('error'));
+ };
+
+ inst.isFormServerValidated = function() {
+ return this.state.formSettings.serverValidation.changed.every(attr => this.state.formState.getIn(['data', attr, 'serverValidated']));
+ };
+
+ inst.getFormStatusMessageText = function() {
+ return this.state.formState.get('statusMessageText');
+ };
+
+ inst.getFormStatusMessageSeverity = function() {
+ return this.state.formState.get('statusMessageSeverity');
+ };
+
+ inst.setFormStatusMessage = function(severity, text) {
+ this.setState(previousState => ({
+ formState: previousState.formState.withMutations(map => {
+ map.set('statusMessageText', text);
+ map.set('statusMessageSeverity', severity);
+ })
+ }));
+ };
+
+ inst.enableForm = function() {
+ this.setState(previousState => ({formState: previousState.formState.set('isDisabled', false)}));
+ };
+
+ inst.disableForm = function() {
+ this.setState(previousState => ({formState: previousState.formState.set('isDisabled', true)}));
+ };
+
+ inst.isFormDisabled = function() {
+ return this.state.formState.get('isDisabled');
+ };
+
+ return target;
+}
+
+
+export {
+ withForm,
+ Form,
+ InputField,
+ TextArea,
+ ButtonRow,
+ Button,
+ TreeTableSelect,
+ FormSendMethod
+}
diff --git a/client/src/lib/page.css b/client/src/lib/page.css
index d67bd1f3..2e996132 100644
--- a/client/src/lib/page.css
+++ b/client/src/lib/page.css
@@ -1,20 +1,24 @@
-.mt-button-row > button {
- margin-right: 15px;
-}
-
-.mt-button-row > button:last-child {
- margin-right: 0px;
-}
-
-.mt-form-status {
- padding-top: 5px;
- padding-bottom: 5px;
-}
-
-.mt-action-links > a {
- margin-right: 8px;
-}
-
-.mt-action-links > a:last-child {
- margin-right: 0px;
-}
+.mt-button-row > button {
+ margin-right: 15px;
+}
+
+.mt-button-row > button:last-child {
+ margin-right: 0px;
+}
+
+.mt-form-status {
+ padding-top: 5px;
+ padding-bottom: 5px;
+}
+
+.mt-action-links > a {
+ margin-right: 8px;
+}
+
+.mt-action-links > a:last-child {
+ margin-right: 0px;
+}
+
+.form-horizontal .control-label {
+ display: block;
+}
\ No newline at end of file
diff --git a/client/src/namespaces/CUD.js b/client/src/namespaces/CUD.js
index 986d5117..69b8dd9e 100644
--- a/client/src/namespaces/CUD.js
+++ b/client/src/namespaces/CUD.js
@@ -23,7 +23,7 @@ export default class CUD extends Component {
this.state.entityId = parseInt(props.match.params.id);
}
- this.initFormState();
+ this.initForm();
this.hasChildren = false;
}
diff --git a/client/src/users/CUD.js b/client/src/users/CUD.js
index 8ac8c07d..49f7a682 100644
--- a/client/src/users/CUD.js
+++ b/client/src/users/CUD.js
@@ -8,6 +8,7 @@ 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 validators from '../../../shared/validators';
import { ModalDialog } from '../lib/bootstrap-components';
@translate()
@@ -26,7 +27,13 @@ export default class CUD extends Component {
this.state.entityId = parseInt(props.match.params.id);
}
- this.initFormState('/users/rest/validate', ['username', 'email']);
+ this.initForm({
+ serverValidation: {
+ url: '/users/rest/validate',
+ changed: ['username', 'email'],
+ extra: ['id']
+ }
+ });
this.hasChildren = false;
}
@@ -36,7 +43,10 @@ export default class CUD extends Component {
@withAsyncErrorHandler
async loadFormValues() {
- await this.getFormValuesFromURL(`/users/rest/users/${this.state.entityId}`);
+ await this.getFormValuesFromURL(`/users/rest/users/${this.state.entityId}`, data => {
+ data.password = '';
+ data.password2 = '';
+ });
}
componentDidMount() {
@@ -46,7 +56,9 @@ export default class CUD extends Component {
this.populateFormValues({
username: '',
name: '',
- email: ''
+ email: '',
+ password: '',
+ password2: ''
});
}
}
@@ -57,12 +69,11 @@ export default class CUD extends Component {
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)) {
+ } else if (!validators.usernameValid(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.'));
@@ -132,7 +143,9 @@ export default class CUD extends Component {
this.disableForm();
this.setFormStatusMessage('info', t('Saving user ...'));
- const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url);
+ const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
+ delete data.password2;
+ });
if (submitSuccessful) {
this.navigateToWithFlashMessage('/users', 'success', t('User saved'));
@@ -179,10 +192,11 @@ export default class CUD extends Component {
render() {
const t = this.props.t;
const edit = this.props.edit;
+ const isAdmin = this.getFormValue('id') === 1;
return (
- {edit &&
+ {edit && !isAdmin &&
-
-
+
+
diff --git a/lib/tools-async.js b/lib/tools-async.js
index 5e5cacf9..7ed49163 100644
--- a/lib/tools-async.js
+++ b/lib/tools-async.js
@@ -23,19 +23,5 @@ async function validateEmail(address, checkBlocked) {
}, 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);
- }
+ return result;
}
diff --git a/models/users.js b/models/users.js
index 1378871e..fa35d39d 100644
--- a/models/users.js
+++ b/models/users.js
@@ -5,9 +5,11 @@ 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 validators = require('../shared/validators');
const dtHelpers = require('../lib/dt-helpers');
-const bcrypt = require('bcrypt-nodejs');
-const tools = require('../lib/tools');
+const tools = require('../lib/tools-async');
+const Promise = require('bluebird');
+const bcryptHash = Promise.promisify(require('bcrypt-nodejs').hash);
const allowedKeys = new Set(['username', 'name', 'email', 'password']);
@@ -16,39 +18,89 @@ function hash(user) {
}
async function getById(userId) {
- const user = await knex('users').select(['id', 'username', 'name', 'email']).where('id', userId).first();
+ const user = await knex('users').select(['id', 'username', 'name', 'email', 'password']).where('id', userId).first();
if (!user) {
throw new interoperableErrors.NotFoundError();
}
user.hash = hash(user);
+ delete(user.password);
+
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();
+async function serverValidate(data) {
+ const result = {};
+
+ if (data.username) {
+ const query = knex('users').select(['id']).where('username', data.username);
+
+ if (data.id) {
+ // Id is not set in entity creation form
+ query.andWhereNot('id', data.id);
+ }
+
+ const user = await query.first();
+ result.username = {
+ exists: !!user
+ };
}
- user.hash = hash(user);
+ if (data.email) {
+ result.email = {};
+ result.email.invalid = await tools.validateEmail(data.email) !== 0;
+ }
- return user;
+ return result;
}
async function listDTAjax(params) {
return await dtHelpers.ajaxList(params, tx => tx('users'), ['users.id', 'users.username', 'users.name']);
}
+async function _validateAndPreprocess(user, isCreate) {
+ enforce(validators.usernameValid(user.username), 'Invalid username');
+
+ const otherUserWithSameUsernameQuery = knex('users').where('username', user.username);
+ if (user.id) {
+ otherUserWithSameUsernameQuery.andWhereNot('id', user.id);
+ }
+
+ const otherUserWithSameUsername = await otherUserWithSameUsernameQuery.first();
+ if (otherUserWithSameUsername) {
+ throw new interoperableErrors.DuplicitNameError();
+ }
+
+ enforce(!isCreate || user.password.length > 0, 'Password not set');
+
+ 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');
+ }
+
+ user.password = await bcryptHash(user.password, null, null);
+ } else {
+ delete user.password;
+ }
+
+ enforce(await tools.validateEmail(user.email) === 0, 'Invalid email');
+}
+
+
async function create(user) {
+ _validateAndPreprocess(user, true);
const userId = await knex('users').insert(filterObject(user, allowedKeys));
return userId;
}
async function updateWithConsistencyCheck(user) {
+ _validateAndPreprocess(user, false);
+
await knex.transaction(async tx => {
- const existingUser = await tx('users').where('id', user.id).first();
+ const existingUser = await tx('users').select(['id', 'username', 'name', 'email', 'password']).where('id', user.id).first();
if (!user) {
throw new interoperableErrors.NotFoundError();
}
@@ -58,41 +110,13 @@ async function updateWithConsistencyCheck(user) {
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
+ enforce(userId !== 1, 'Admin cannot be deleted');
await knex('users').where('id', userId).del();
}
@@ -103,5 +127,5 @@ module.exports = {
create,
hash,
getById,
- getByUsername
+ serverValidate
};
\ No newline at end of file
diff --git a/routes/users.js b/routes/users.js
index f0e0b71e..c4b273aa 100644
--- a/routes/users.js
+++ b/routes/users.js
@@ -5,7 +5,6 @@ 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.all('/rest/*', (req, res, next) => {
@@ -42,39 +41,9 @@ router.deleteAsync('/rest/users/:userId', passport.csrfProtection, async (req, r
});
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;
- }
- }
- }
-
- 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);
+ return res.json(await users.serverValidate(req.body));
});
-
router.postAsync('/rest/usersTable', async (req, res) => {
return res.json(await users.listDTAjax(req.body));
});
diff --git a/setup/knex/migrations/20170506102634_base.js b/setup/knex/migrations/20170506102634_base.js
index 95987752..b6b31ab7 100644
--- a/setup/knex/migrations/20170506102634_base.js
+++ b/setup/knex/migrations/20170506102634_base.js
@@ -1,75 +1,75 @@
-exports.up = function(knex, Promise) {
-/* This is shows what it would look like when we specify the "users" table with Knex.
- In some sense, this is probably the most complicated table we have in Mailtrain.
-
- return knex.schema.hasTable('users'))
- .then(exists => {
- if (!exists) {
- return knex.schema.createTable('users', table => {
- table.increments('id').primary();
- table.string('username').notNullable().defaultTo('');
- table.string('password').notNullable().defaultTo('');
- table.string('email').notNullable();
- table.string('access_token', 40).index();
- table.string('reset_token').index();
- table.dateTime('reset_expire');
- table.timestamp('created').defaultTo(knex.fn.now());
- })
-
- // INNODB tables have the limit of 767 bytes for an index.
- // Combined with the charset used, this poses limits on the size of keys. Knex does not offer API
- // for such settings, thus we resort to raw queries.
- .raw('ALTER TABLE `users` MODIFY `email` VARCHAR(255) CHARACTER SET utf8 NOT NULL')
- .raw('ALTER TABLE `users` ADD UNIQUE KEY `email` (`email`)')
- .raw('ALTER TABLE `users` ADD KEY `username` (`username`(191))')
- .raw('ALTER TABLE `users` ADD KEY `check_reset` (`username`(191),`reset_token`,`reset_expire`)')
-
- .then(() => knex('users').insert({
- id: 1,
- username: 'admin',
- password: '$2a$10$FZV.tFT252o4iiHoZ9b2sOZOc.EBDOcY2.9HNCtNwshtSLf21mB1i',
- email: 'hostmaster@sathyasai.org'
- }));
- }
- });
-*/
-
- // We should check here if the tables already exist and upgrade them to db_schema_version 28, which is the baseline.
- // For now, we just check whether our DB is up-to-date based on the existing SQL migration infrastructure in Mailtrain.
- return knex('settings').where({key: 'db_schema_version'}).first('value')
- .then(row => {
- if (!row || Number(row.value) !== 29) {
- throw new Error('Unsupported DB schema version: ' + row.value);
- }
- })
-
- // We have to update data types of primary keys and related foreign keys. Mailtrain uses unsigned int(11), while
- // Knex uses unsigned int (which is unsigned int(10) ).
- .then(() => knex.schema
- .raw('ALTER TABLE `users` MODIFY `id` int unsigned not null auto_increment')
- .raw('ALTER TABLE `lists` MODIFY `id` int unsigned not null auto_increment')
- .raw('ALTER TABLE `confirmations` MODIFY `list` int unsigned not null')
- .raw('ALTER TABLE `custom_fields` MODIFY `list` int unsigned not null')
- .raw('ALTER TABLE `importer` MODIFY `list` int unsigned not null')
- .raw('ALTER TABLE `segments` MODIFY `list` int unsigned not null')
- .raw('ALTER TABLE `triggers` MODIFY `list` int unsigned not null')
- .raw('ALTER TABLE `custom_forms` MODIFY `list` int unsigned not null')
- )
-
-/*
- Remaining foreign keys:
- -----------------------
-
- links campaign campaigns id
- segment_rules segment segments id
- import_failed import importer id
- rss parent campaigns id
- attachments campaign campaigns id
- custom_forms_data form custom_forms id
- report_template report_template report_templates id
-*/
-};
-
-exports.down = function(knex, Promise) {
- // return knex.schema.dropTable('users');
+exports.up = function(knex, Promise) {
+/* This is shows what it would look like when we specify the "users" table with Knex.
+ In some sense, this is probably the most complicated table we have in Mailtrain.
+
+ return knex.schema.hasTable('users'))
+ .then(exists => {
+ if (!exists) {
+ return knex.schema.createTable('users', table => {
+ table.increments('id').primary();
+ table.string('username').notNullable();
+ table.string('password').notNullable();
+ table.string('email').notNullable();
+ table.string('access_token', 40).index();
+ table.string('reset_token').index();
+ table.dateTime('reset_expire');
+ table.timestamp('created').defaultTo(knex.fn.now());
+ })
+
+ // INNODB tables have the limit of 767 bytes for an index.
+ // Combined with the charset used, this poses limits on the size of keys. Knex does not offer API
+ // for such settings, thus we resort to raw queries.
+ .raw('ALTER TABLE `users` MODIFY `email` VARCHAR(255) CHARACTER SET utf8 NOT NULL')
+ .raw('ALTER TABLE `users` ADD UNIQUE KEY `email` (`email`)')
+ .raw('ALTER TABLE `users` ADD KEY `username` (`username`(191))')
+ .raw('ALTER TABLE `users` ADD KEY `check_reset` (`username`(191),`reset_token`,`reset_expire`)')
+
+ .then(() => knex('users').insert({
+ id: 1,
+ username: 'admin',
+ password: '$2a$10$FZV.tFT252o4iiHoZ9b2sOZOc.EBDOcY2.9HNCtNwshtSLf21mB1i',
+ email: 'hostmaster@sathyasai.org'
+ }));
+ }
+ });
+*/
+
+ // We should check here if the tables already exist and upgrade them to db_schema_version 28, which is the baseline.
+ // For now, we just check whether our DB is up-to-date based on the existing SQL migration infrastructure in Mailtrain.
+ return knex('settings').where({key: 'db_schema_version'}).first('value')
+ .then(row => {
+ if (!row || Number(row.value) !== 29) {
+ throw new Error('Unsupported DB schema version: ' + row.value);
+ }
+ })
+
+ // We have to update data types of primary keys and related foreign keys. Mailtrain uses unsigned int(11), while
+ // Knex uses unsigned int (which is unsigned int(10) ).
+ .then(() => knex.schema
+ .raw('ALTER TABLE `users` MODIFY `id` int unsigned not null auto_increment')
+ .raw('ALTER TABLE `lists` MODIFY `id` int unsigned not null auto_increment')
+ .raw('ALTER TABLE `confirmations` MODIFY `list` int unsigned not null')
+ .raw('ALTER TABLE `custom_fields` MODIFY `list` int unsigned not null')
+ .raw('ALTER TABLE `importer` MODIFY `list` int unsigned not null')
+ .raw('ALTER TABLE `segments` MODIFY `list` int unsigned not null')
+ .raw('ALTER TABLE `triggers` MODIFY `list` int unsigned not null')
+ .raw('ALTER TABLE `custom_forms` MODIFY `list` int unsigned not null')
+ )
+
+/*
+ Remaining foreign keys:
+ -----------------------
+
+ links campaign campaigns id
+ segment_rules segment segments id
+ import_failed import importer id
+ rss parent campaigns id
+ attachments campaign campaigns id
+ custom_forms_data form custom_forms id
+ report_template report_template report_templates id
+*/
+};
+
+exports.down = function(knex, Promise) {
+ // return knex.schema.dropTable('users');
};
\ No newline at end of file
diff --git a/setup/knex/migrations/20170617123450_create_user_name.js b/setup/knex/migrations/20170617123450_create_user_name.js
index 1ce06348..00aa9b65 100644
--- a/setup/knex/migrations/20170617123450_create_user_name.js
+++ b/setup/knex/migrations/20170617123450_create_user_name.js
@@ -1,7 +1,10 @@
exports.up = function(knex, Promise) {
return knex.schema.table('users', table => {
- table.string('name');
+ table.string('name').notNullable().default('');
})
+ .then(() => knex('users').where('id', 1).update({
+ name: 'Administrator'
+ }));
};
exports.down = function(knex, Promise) {
diff --git a/shared/validators.js b/shared/validators.js
new file mode 100644
index 00000000..90a32376
--- /dev/null
+++ b/shared/validators.js
@@ -0,0 +1,9 @@
+'use strict';
+
+function usernameValid(username) {
+ return /^[a-zA-Z0-9][a-zA-Z0-9_\-.]*$/.test(username);
+}
+
+module.exports = {
+ usernameValid
+};
\ No newline at end of file