React-based /account endpoint for editing a user profile
This commit is contained in:
parent
09fe27fe2b
commit
fbb8f5799e
14 changed files with 386 additions and 51 deletions
2
app.js
2
app.js
|
@ -45,6 +45,7 @@ const reportsTemplates = require('./routes/report-templates');
|
||||||
|
|
||||||
const namespaces = require('./routes/namespaces');
|
const namespaces = require('./routes/namespaces');
|
||||||
const users = require('./routes/users');
|
const users = require('./routes/users');
|
||||||
|
const account = require('./routes/account');
|
||||||
|
|
||||||
const interoperableErrors = require('./shared/interoperable-errors');
|
const interoperableErrors = require('./shared/interoperable-errors');
|
||||||
|
|
||||||
|
@ -229,6 +230,7 @@ app.use('/mosaico', mosaico);
|
||||||
|
|
||||||
app.use('/namespaces', namespaces);
|
app.use('/namespaces', namespaces);
|
||||||
app.use('/users', users);
|
app.use('/users', users);
|
||||||
|
app.use('/account', account);
|
||||||
|
|
||||||
if (config.reports && config.reports.enabled === true) {
|
if (config.reports && config.reports.enabled === true) {
|
||||||
app.use('/reports', reports);
|
app.use('/reports', reports);
|
||||||
|
|
154
client/src/account/Account.js
Normal file
154
client/src/account/Account.js
Normal file
|
@ -0,0 +1,154 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { translate } from 'react-i18next';
|
||||||
|
import { withPageHelpers, Title } from '../lib/page'
|
||||||
|
import {
|
||||||
|
withForm, Form, Fieldset, FormSendMethod, InputField, ButtonRow, Button
|
||||||
|
} from '../lib/form';
|
||||||
|
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
|
||||||
|
import passwordValidator from '../../../shared/password-validator';
|
||||||
|
|
||||||
|
@translate()
|
||||||
|
@withForm
|
||||||
|
@withPageHelpers
|
||||||
|
@withErrorHandling
|
||||||
|
export default class Account extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.passwordValidator = passwordValidator(props.t);
|
||||||
|
|
||||||
|
this.state = {};
|
||||||
|
|
||||||
|
this.initForm({
|
||||||
|
serverValidation: {
|
||||||
|
url: '/account/rest/account-validate',
|
||||||
|
changed: ['email', 'password', 'currentPassword']
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@withAsyncErrorHandler
|
||||||
|
async loadFormValues() {
|
||||||
|
await this.getFormValuesFromURL(`/account/rest/account`, data => {
|
||||||
|
data.password = '';
|
||||||
|
data.password2 = '';
|
||||||
|
data.currentPassword = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.loadFormValues();
|
||||||
|
}
|
||||||
|
|
||||||
|
localValidateFormValues(state) {
|
||||||
|
const t = this.props.t;
|
||||||
|
|
||||||
|
const email = state.getIn(['email', 'value']);
|
||||||
|
const emailServerValidation = state.getIn(['email', 'serverValidation']);
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
state.setIn(['email', 'error'], t('Email must not be empty.'));
|
||||||
|
} else if (!emailServerValidation || emailServerValidation.invalid) {
|
||||||
|
state.setIn(['email', 'error'], t('Invalid email address.'));
|
||||||
|
} else if (!emailServerValidation || emailServerValidation.exists) {
|
||||||
|
state.setIn(['email', 'error'], t('The email is already associated with another user in the system.'));
|
||||||
|
} 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 currentPassword = state.getIn(['currentPassword', 'value']) || '';
|
||||||
|
|
||||||
|
let passwordMsgs = [];
|
||||||
|
|
||||||
|
if (password || currentPassword) {
|
||||||
|
const passwordResults = this.passwordValidator.test(password);
|
||||||
|
|
||||||
|
passwordMsgs.push(...passwordResults.errors);
|
||||||
|
|
||||||
|
const currentPasswordServerValidation = state.getIn(['currentPassword', 'serverValidation']);
|
||||||
|
|
||||||
|
if (!currentPassword) {
|
||||||
|
state.setIn(['currentPassword', 'error'], t('Current password must not be empty.'));
|
||||||
|
} else if (!currentPasswordServerValidation || currentPasswordServerValidation.incorrect) {
|
||||||
|
state.setIn(['currentPassword', 'error'], t('Incorrect password.'));
|
||||||
|
} else {
|
||||||
|
state.setIn(['currentPassword', 'error'], null);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
this.disableForm();
|
||||||
|
this.setFormStatusMessage('info', t('Updating user profile ...'));
|
||||||
|
|
||||||
|
const submitSuccessful = await this.validateAndSendFormValuesToURL(FormSendMethod.POST, '/account/rest/account', data => {
|
||||||
|
delete data.password2;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.enableForm();
|
||||||
|
|
||||||
|
if (submitSuccessful) {
|
||||||
|
this.setFlashMessage('success', t('User profile updated'));
|
||||||
|
this.hideFormValidation();
|
||||||
|
this.updateFormValue('password', '');
|
||||||
|
this.updateFormValue('password2', '');
|
||||||
|
this.updateFormValue('currentPassword', '');
|
||||||
|
|
||||||
|
this.clearFormStatusMessage();
|
||||||
|
} else {
|
||||||
|
this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const t = this.props.t;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Title>{t('Account')}</Title>
|
||||||
|
|
||||||
|
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
|
||||||
|
<Fieldset label={t('General Settings')}>
|
||||||
|
<InputField id="name" label={t('Full Name')}/>
|
||||||
|
<InputField id="email" label={t('Email')} help={t('This address is used for account recovery in case you loose your password')}/>
|
||||||
|
</Fieldset>
|
||||||
|
|
||||||
|
<Fieldset label={t('Password Change')}>
|
||||||
|
<p>{t('You only need to fill out this form if you want to change your current password')}</p>
|
||||||
|
<InputField id="currentPassword" label={t('Current Password')} type="password" />
|
||||||
|
<InputField id="password" label={t('New Password')} type="password" />
|
||||||
|
<InputField id="password2" label={t('Confirm Password')} type="password" />
|
||||||
|
</Fieldset>
|
||||||
|
|
||||||
|
<ButtonRow>
|
||||||
|
<Button type="submit" className="btn-primary" icon="ok" label={t('Update')}/>
|
||||||
|
</ButtonRow>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
32
client/src/account/root.js
Normal file
32
client/src/account/root.js
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
'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 Account from './Account'
|
||||||
|
|
||||||
|
const getStructure = t => ({
|
||||||
|
'': {
|
||||||
|
title: t('Home'),
|
||||||
|
externalLink: '/',
|
||||||
|
children: {
|
||||||
|
'account': {
|
||||||
|
title: t('Account'),
|
||||||
|
link: '/account',
|
||||||
|
component: Account
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function() {
|
||||||
|
ReactDOM.render(
|
||||||
|
<I18nextProvider i18n={ i18n }><Section root='/account' structure={getStructure}/></I18nextProvider>,
|
||||||
|
document.getElementById('root')
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
|
@ -94,7 +94,26 @@ class Form extends Component {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function wrapInput(id, htmlId, owner, label, input) {
|
class Fieldset extends Component {
|
||||||
|
static propTypes = {
|
||||||
|
label: PropTypes.string
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const props = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset>
|
||||||
|
{props.label ? <legend>{props.label}</legend> : null}
|
||||||
|
{props.children}
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapInput(id, htmlId, owner, label, help, input) {
|
||||||
|
const helpBlock = help ? <div className="help-block col-sm-offset-2 col-sm-10" id={htmlId + '_help'}>{help}</div> : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={owner.addFormValidationClass('form-group', id)} >
|
<div className={owner.addFormValidationClass('form-group', id)} >
|
||||||
<div className="col-sm-2">
|
<div className="col-sm-2">
|
||||||
|
@ -103,7 +122,8 @@ function wrapInput(id, htmlId, owner, label, input) {
|
||||||
<div className="col-sm-10">
|
<div className="col-sm-10">
|
||||||
{input}
|
{input}
|
||||||
</div>
|
</div>
|
||||||
<div className="help-block col-sm-offset-2 col-sm-10" id={htmlId + '_help'}>{owner.getFormValidationMessage(id)}</div>
|
{helpBlock}
|
||||||
|
<div className="help-block col-sm-offset-2 col-sm-10" id={htmlId + '_help_validation'}>{owner.getFormValidationMessage(id)}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -113,7 +133,8 @@ class InputField extends Component {
|
||||||
id: PropTypes.string.isRequired,
|
id: PropTypes.string.isRequired,
|
||||||
label: PropTypes.string.isRequired,
|
label: PropTypes.string.isRequired,
|
||||||
placeholder: PropTypes.string,
|
placeholder: PropTypes.string,
|
||||||
type: PropTypes.string
|
type: PropTypes.string,
|
||||||
|
help: PropTypes.string
|
||||||
}
|
}
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
|
@ -135,7 +156,7 @@ class InputField extends Component {
|
||||||
type = 'password';
|
type = 'password';
|
||||||
}
|
}
|
||||||
|
|
||||||
return wrapInput(id, htmlId, owner, props.label,
|
return wrapInput(id, htmlId, owner, props.label, props.help,
|
||||||
<input type={type} value={owner.getFormValue(id)} placeholder={props.placeholder} id={htmlId} className="form-control" aria-describedby={htmlId + '_help'} onChange={evt => owner.updateFormValue(id, evt.target.value)}/>
|
<input type={type} value={owner.getFormValue(id)} placeholder={props.placeholder} id={htmlId} className="form-control" aria-describedby={htmlId + '_help'} onChange={evt => owner.updateFormValue(id, evt.target.value)}/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -145,7 +166,8 @@ class TextArea extends Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
id: PropTypes.string.isRequired,
|
id: PropTypes.string.isRequired,
|
||||||
label: PropTypes.string.isRequired,
|
label: PropTypes.string.isRequired,
|
||||||
placeholder: PropTypes.string
|
placeholder: PropTypes.string,
|
||||||
|
help: PropTypes.string
|
||||||
}
|
}
|
||||||
|
|
||||||
static contextTypes = {
|
static contextTypes = {
|
||||||
|
@ -158,7 +180,7 @@ class TextArea extends Component {
|
||||||
const id = this.props.id;
|
const id = this.props.id;
|
||||||
const htmlId = 'form_' + id;
|
const htmlId = 'form_' + id;
|
||||||
|
|
||||||
return wrapInput(id, htmlId, owner, props.label,
|
return wrapInput(id, htmlId, owner, props.label, props.help,
|
||||||
<textarea id={htmlId} value={owner.getFormValue(id)} className="form-control" aria-describedby={htmlId + '_help'} onChange={evt => owner.updateFormValue(id, evt.target.value)}></textarea>
|
<textarea id={htmlId} value={owner.getFormValue(id)} className="form-control" aria-describedby={htmlId + '_help'} onChange={evt => owner.updateFormValue(id, evt.target.value)}></textarea>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -238,7 +260,8 @@ class TreeTableSelect extends Component {
|
||||||
id: PropTypes.string.isRequired,
|
id: PropTypes.string.isRequired,
|
||||||
label: PropTypes.string.isRequired,
|
label: PropTypes.string.isRequired,
|
||||||
dataUrl: PropTypes.string,
|
dataUrl: PropTypes.string,
|
||||||
data: PropTypes.array
|
data: PropTypes.array,
|
||||||
|
help: PropTypes.string
|
||||||
}
|
}
|
||||||
|
|
||||||
static contextTypes = {
|
static contextTypes = {
|
||||||
|
@ -256,16 +279,8 @@ class TreeTableSelect extends Component {
|
||||||
const id = this.props.id;
|
const id = this.props.id;
|
||||||
const htmlId = 'form_' + id;
|
const htmlId = 'form_' + id;
|
||||||
|
|
||||||
return (
|
return wrapInput(id, htmlId, owner, props.label, props.help,
|
||||||
<div className={owner.addFormValidationClass('form-group', id)} >
|
<TreeTable data={this.props.data} dataUrl={this.props.dataUrl} selectMode={TreeSelectMode.SINGLE} selection={owner.getFormValue(id)} onSelectionChangedAsync={::this.onSelectionChangedAsync}/>
|
||||||
<div className="col-sm-2">
|
|
||||||
<label htmlFor={htmlId} className="control-label">{props.label}</label>
|
|
||||||
</div>
|
|
||||||
<div className="col-sm-10">
|
|
||||||
<TreeTable data={this.props.data} dataUrl={this.props.dataUrl} selectMode={TreeSelectMode.SINGLE} selection={owner.getFormValue(id)} onSelectionChangedAsync={::this.onSelectionChangedAsync}/>
|
|
||||||
</div>
|
|
||||||
<div className="help-block col-sm-offset-2 col-sm-10" id={htmlId + '_help'}>{owner.getFormValidationMessage(id)}</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -546,6 +561,14 @@ function withForm(target) {
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
inst.clearFormStatusMessage = function() {
|
||||||
|
this.setState(previousState => ({
|
||||||
|
formState: previousState.formState.withMutations(map => {
|
||||||
|
map.set('statusMessageText', '');
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
inst.enableForm = function() {
|
inst.enableForm = function() {
|
||||||
this.setState(previousState => ({formState: previousState.formState.set('isDisabled', false)}));
|
this.setState(previousState => ({formState: previousState.formState.set('isDisabled', false)}));
|
||||||
};
|
};
|
||||||
|
@ -565,6 +588,7 @@ function withForm(target) {
|
||||||
export {
|
export {
|
||||||
withForm,
|
withForm,
|
||||||
Form,
|
Form,
|
||||||
|
Fieldset,
|
||||||
InputField,
|
InputField,
|
||||||
TextArea,
|
TextArea,
|
||||||
ButtonRow,
|
ButtonRow,
|
||||||
|
|
|
@ -57,7 +57,7 @@ export default class CUD extends Component {
|
||||||
|
|
||||||
@withAsyncErrorHandler
|
@withAsyncErrorHandler
|
||||||
async loadTreeData() {
|
async loadTreeData() {
|
||||||
axios.get('/namespaces/rest/namespacesTree')
|
axios.get('/namespaces/rest/namespaces-tree')
|
||||||
.then(response => {
|
.then(response => {
|
||||||
|
|
||||||
response.data.expanded = true;
|
response.data.expanded = true;
|
||||||
|
|
|
@ -25,7 +25,7 @@ export default class List extends Component {
|
||||||
|
|
||||||
<Title>{t('Namespaces')}</Title>
|
<Title>{t('Namespaces')}</Title>
|
||||||
|
|
||||||
<TreeTable withHeader dataUrl="/namespaces/rest/namespacesTree" actionLinks={actionLinks} />
|
<TreeTable withHeader dataUrl="/namespaces/rest/namespaces-tree" actionLinks={actionLinks} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
import { translate } from 'react-i18next';
|
import { translate } from 'react-i18next';
|
||||||
import { withPageHelpers, Title } from '../lib/page'
|
import { withPageHelpers, Title } from '../lib/page'
|
||||||
import { withForm, Form, FormSendMethod, InputField, TextArea, ButtonRow, Button, TreeTableSelect } from '../lib/form';
|
import { withForm, Form, FormSendMethod, InputField, ButtonRow, Button } from '../lib/form';
|
||||||
import axios from '../lib/axios';
|
import axios from '../lib/axios';
|
||||||
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
|
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
|
||||||
import interoperableErrors from '../../../shared/interoperable-errors';
|
import interoperableErrors from '../../../shared/interoperable-errors';
|
||||||
|
@ -29,12 +29,11 @@ export default class CUD extends Component {
|
||||||
|
|
||||||
this.initForm({
|
this.initForm({
|
||||||
serverValidation: {
|
serverValidation: {
|
||||||
url: '/users/rest/validate',
|
url: '/users/rest/users-validate',
|
||||||
changed: ['username', 'email'],
|
changed: ['username', 'email'],
|
||||||
extra: ['id']
|
extra: ['id']
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.hasChildren = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isDelete() {
|
isDelete() {
|
||||||
|
@ -89,6 +88,8 @@ export default class CUD extends Component {
|
||||||
state.setIn(['email', 'error'], t('Email must not be empty'));
|
state.setIn(['email', 'error'], t('Email must not be empty'));
|
||||||
} else if (!emailServerValidation || emailServerValidation.invalid) {
|
} else if (!emailServerValidation || emailServerValidation.invalid) {
|
||||||
state.setIn(['email', 'error'], t('Invalid email address.'));
|
state.setIn(['email', 'error'], t('Invalid email address.'));
|
||||||
|
} else if (!emailServerValidation || emailServerValidation.exists) {
|
||||||
|
state.setIn(['email', 'error'], t('The email is already associated with another user in the system.'));
|
||||||
} else {
|
} else {
|
||||||
state.setIn(['email', 'error'], null);
|
state.setIn(['email', 'error'], null);
|
||||||
}
|
}
|
||||||
|
@ -106,8 +107,6 @@ export default class CUD extends Component {
|
||||||
const password = state.getIn(['password', 'value']) || '';
|
const password = state.getIn(['password', 'value']) || '';
|
||||||
const password2 = state.getIn(['password2', 'value']) || '';
|
const password2 = state.getIn(['password2', 'value']) || '';
|
||||||
|
|
||||||
const passwordResults = this.passwordValidator.test(password);
|
|
||||||
|
|
||||||
let passwordMsgs = [];
|
let passwordMsgs = [];
|
||||||
|
|
||||||
if (!edit && !password) {
|
if (!edit && !password) {
|
||||||
|
@ -115,6 +114,7 @@ export default class CUD extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password) {
|
if (password) {
|
||||||
|
const passwordResults = this.passwordValidator.test(password);
|
||||||
passwordMsgs.push(...passwordResults.errors);
|
passwordMsgs.push(...passwordResults.errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -154,7 +154,7 @@ export default class CUD extends Component {
|
||||||
this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.'));
|
this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.'));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof interoperableErrors.LoopDetectedError) {
|
if (error instanceof interoperableErrors.DuplicitNameError) {
|
||||||
this.setFormStatusMessage('danger',
|
this.setFormStatusMessage('danger',
|
||||||
<span>
|
<span>
|
||||||
<strong>{t('Your updates cannot be saved.')}</strong>{' '}
|
<strong>{t('Your updates cannot be saved.')}</strong>{' '}
|
||||||
|
@ -164,6 +164,16 @@ export default class CUD extends Component {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error instanceof interoperableErrors.DuplicitEmailError) {
|
||||||
|
this.setFormStatusMessage('danger',
|
||||||
|
<span>
|
||||||
|
<strong>{t('Your updates cannot be saved.')}</strong>{' '}
|
||||||
|
{t('The email is already assigned to another user.')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
import { translate } from 'react-i18next';
|
import { translate } from 'react-i18next';
|
||||||
import { Title, Toolbar, NavButton } from '../lib/page';
|
import { Title, Toolbar, NavButton } from '../lib/page';
|
||||||
import { Table, TableSelectMode } from '../lib/table';
|
import { Table } from '../lib/table';
|
||||||
|
|
||||||
@translate()
|
@translate()
|
||||||
export default class List extends Component {
|
export default class List extends Component {
|
||||||
|
@ -31,7 +31,7 @@ export default class List extends Component {
|
||||||
|
|
||||||
<Title>{t('Users')}</Title>
|
<Title>{t('Users')}</Title>
|
||||||
|
|
||||||
<Table withHeader dataUrl="/users/rest/usersTable" columns={columns} actionLinks={actionLinks} />
|
<Table withHeader dataUrl="/users/rest/users-table" columns={columns} actionLinks={actionLinks} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,8 @@ const path = require('path');
|
||||||
module.exports = {
|
module.exports = {
|
||||||
entry: {
|
entry: {
|
||||||
namespaces: ['babel-polyfill', './src/namespaces/root.js'],
|
namespaces: ['babel-polyfill', './src/namespaces/root.js'],
|
||||||
users: ['babel-polyfill', './src/users/root.js']
|
users: ['babel-polyfill', './src/users/root.js'],
|
||||||
|
account: ['babel-polyfill', './src/account/root.js']
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
library: 'MailtrainReactBody',
|
library: 'MailtrainReactBody',
|
||||||
|
|
|
@ -9,9 +9,12 @@ const validators = require('../shared/validators');
|
||||||
const dtHelpers = require('../lib/dt-helpers');
|
const dtHelpers = require('../lib/dt-helpers');
|
||||||
const tools = require('../lib/tools-async');
|
const tools = require('../lib/tools-async');
|
||||||
const Promise = require('bluebird');
|
const Promise = require('bluebird');
|
||||||
const bcryptHash = Promise.promisify(require('bcrypt-nodejs').hash);
|
const bcrypt = require('bcrypt-nodejs');
|
||||||
|
const bcryptHash = Promise.promisify(bcrypt.hash);
|
||||||
|
const bcryptCompare = Promise.promisify(bcrypt.compare);
|
||||||
|
|
||||||
const allowedKeys = new Set(['username', 'name', 'email', 'password']);
|
const allowedKeys = new Set(['username', 'name', 'email', 'password']);
|
||||||
|
const ownAccountAllowedKeys = new Set(['name', 'email', 'password']);
|
||||||
|
|
||||||
function hash(user) {
|
function hash(user) {
|
||||||
return hasher.hash(filterObject(user, allowedKeys));
|
return hasher.hash(filterObject(user, allowedKeys));
|
||||||
|
@ -30,10 +33,10 @@ async function getById(userId) {
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function serverValidate(data) {
|
async function serverValidate(data, isOwnAccount) {
|
||||||
const result = {};
|
const result = {};
|
||||||
|
|
||||||
if (data.username) {
|
if (!isOwnAccount && data.username) {
|
||||||
const query = knex('users').select(['id']).where('username', data.username);
|
const query = knex('users').select(['id']).where('username', data.username);
|
||||||
|
|
||||||
if (data.id) {
|
if (data.id) {
|
||||||
|
@ -47,9 +50,26 @@ async function serverValidate(data) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isOwnAccount && data.currentPassword) {
|
||||||
|
const user = await knex('users').select(['id', 'password']).where('id', data.id).first();
|
||||||
|
|
||||||
|
result.currentPassword = {};
|
||||||
|
result.currentPassword.incorrect = !await bcryptCompare(data.currentPassword, user.password);
|
||||||
|
}
|
||||||
|
|
||||||
if (data.email) {
|
if (data.email) {
|
||||||
|
const query = knex('users').select(['id']).where('email', data.email);
|
||||||
|
|
||||||
|
if (data.id) {
|
||||||
|
// Id is not set in entity creation form
|
||||||
|
query.andWhereNot('id', data.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await query.first();
|
||||||
|
|
||||||
result.email = {};
|
result.email = {};
|
||||||
result.email.invalid = await tools.validateEmail(data.email) !== 0;
|
result.email.invalid = await tools.validateEmail(data.email) !== 0;
|
||||||
|
result.email.exists = !!user;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
@ -59,19 +79,35 @@ async function listDTAjax(params) {
|
||||||
return await dtHelpers.ajaxList(params, tx => tx('users'), ['users.id', 'users.username', 'users.name']);
|
return await dtHelpers.ajaxList(params, tx => tx('users'), ['users.id', 'users.username', 'users.name']);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _validateAndPreprocess(user, isCreate) {
|
async function _validateAndPreprocess(tx, user, isCreate, isOwnAccount) {
|
||||||
enforce(validators.usernameValid(user.username), 'Invalid username');
|
enforce(await tools.validateEmail(user.email) === 0, 'Invalid email');
|
||||||
|
|
||||||
const otherUserWithSameUsernameQuery = knex('users').where('username', user.username);
|
const otherUserWithSameEmailQuery = tx('users').where('email', user.email);
|
||||||
if (user.id) {
|
if (user.id) {
|
||||||
otherUserWithSameUsernameQuery.andWhereNot('id', user.id);
|
otherUserWithSameEmailQuery.andWhereNot('id', user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const otherUserWithSameUsername = await otherUserWithSameUsernameQuery.first();
|
const otherUserWithSameUsername = await otherUserWithSameEmailQuery.first();
|
||||||
if (otherUserWithSameUsername) {
|
if (otherUserWithSameUsername) {
|
||||||
throw new interoperableErrors.DuplicitNameError();
|
throw new interoperableErrors.DuplicitEmailError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (!isOwnAccount) {
|
||||||
|
enforce(validators.usernameValid(user.username), 'Invalid username');
|
||||||
|
|
||||||
|
const otherUserWithSameUsernameQuery = tx('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');
|
enforce(!isCreate || user.password.length > 0, 'Password not set');
|
||||||
|
|
||||||
if (user.password) {
|
if (user.password) {
|
||||||
|
@ -85,21 +121,21 @@ async function _validateAndPreprocess(user, isCreate) {
|
||||||
} else {
|
} else {
|
||||||
delete user.password;
|
delete user.password;
|
||||||
}
|
}
|
||||||
|
|
||||||
enforce(await tools.validateEmail(user.email) === 0, 'Invalid email');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function create(user) {
|
async function create(user) {
|
||||||
_validateAndPreprocess(user, true);
|
await knex.transaction(async tx => {
|
||||||
const userId = await knex('users').insert(filterObject(user, allowedKeys));
|
await _validateAndPreprocess(tx, user, true);
|
||||||
return userId;
|
const userId = await tx('users').insert(filterObject(user, allowedKeys));
|
||||||
|
return userId;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateWithConsistencyCheck(user) {
|
async function updateWithConsistencyCheck(user, isOwnAccount) {
|
||||||
_validateAndPreprocess(user, false);
|
|
||||||
|
|
||||||
await knex.transaction(async tx => {
|
await knex.transaction(async tx => {
|
||||||
|
await _validateAndPreprocess(tx, user, false, isOwnAccount);
|
||||||
|
|
||||||
const existingUser = await tx('users').select(['id', 'username', 'name', 'email', 'password']).where('id', user.id).first();
|
const existingUser = await tx('users').select(['id', 'username', 'name', 'email', 'password']).where('id', user.id).first();
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new interoperableErrors.NotFoundError();
|
throw new interoperableErrors.NotFoundError();
|
||||||
|
@ -110,7 +146,18 @@ async function updateWithConsistencyCheck(user) {
|
||||||
throw new interoperableErrors.ChangedError();
|
throw new interoperableErrors.ChangedError();
|
||||||
}
|
}
|
||||||
|
|
||||||
await tx('users').where('id', user.id).update(filterObject(user, allowedKeys));
|
if (isOwnAccount && user.password) {
|
||||||
|
console.log(user.currentPassword);
|
||||||
|
console.log(existingUser.password);
|
||||||
|
|
||||||
|
if (!await bcryptCompare(user.currentPassword, existingUser.password)) {
|
||||||
|
// This is not an interoperable error because current password is verified in account-validate.
|
||||||
|
// A change of password between account-validate and submit would be signalled by ChangedError
|
||||||
|
throw new Error('Incorrect password');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx('users').where('id', user.id).update(filterObject(user, isOwnAccount ? ownAccountAllowedKeys : allowedKeys));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
59
routes/account.js
Normal file
59
routes/account.js
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
|
||||||
|
router.all('/rest/*', (req, res, next) => {
|
||||||
|
req.needsJSONResponse = true;
|
||||||
|
|
||||||
|
if (!req.user) {
|
||||||
|
throw new interoperableErrors.NotLoggedInError();
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.getAsync('/rest/account', async (req, res) => {
|
||||||
|
const user = await users.getById(req.user.id);
|
||||||
|
return res.json(user);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.postAsync('/rest/account', passport.csrfProtection, async (req, res) => {
|
||||||
|
const data = req.body;
|
||||||
|
data.id = req.user.id;
|
||||||
|
|
||||||
|
await users.updateWithConsistencyCheck(req.body, true);
|
||||||
|
return res.json();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.postAsync('/rest/account-validate', async (req, res) => {
|
||||||
|
const data = req.body;
|
||||||
|
data.id = req.user.id;
|
||||||
|
|
||||||
|
return res.json(await users.serverValidate(data, true));
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
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.getAsync('/*', passport.csrfProtection, async (req, res) => {
|
||||||
|
res.render('react-root', {
|
||||||
|
title: _('Account'),
|
||||||
|
reactEntryPoint: 'account',
|
||||||
|
reactCsrfToken: req.csrfToken()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = router;
|
|
@ -39,7 +39,7 @@ router.deleteAsync('/rest/namespaces/:nsId', passport.csrfProtection, async (req
|
||||||
return res.json();
|
return res.json();
|
||||||
});
|
});
|
||||||
|
|
||||||
router.getAsync('/rest/namespacesTree', async (req, res) => {
|
router.getAsync('/rest/namespaces-tree', async (req, res) => {
|
||||||
const entries = {};
|
const entries = {};
|
||||||
let root; // Only the Root namespace is without a parent
|
let root; // Only the Root namespace is without a parent
|
||||||
const rows = await namespaces.list();
|
const rows = await namespaces.list();
|
||||||
|
|
|
@ -40,16 +40,15 @@ router.deleteAsync('/rest/users/:userId', passport.csrfProtection, async (req, r
|
||||||
return res.json();
|
return res.json();
|
||||||
});
|
});
|
||||||
|
|
||||||
router.postAsync('/rest/validate', async (req, res) => {
|
router.postAsync('/rest/users-validate', async (req, res) => {
|
||||||
return res.json(await users.serverValidate(req.body));
|
return res.json(await users.serverValidate(req.body));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.postAsync('/rest/usersTable', async (req, res) => {
|
router.postAsync('/rest/users-table', async (req, res) => {
|
||||||
return res.json(await users.listDTAjax(req.body));
|
return res.json(await users.listDTAjax(req.body));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
router.all('/*', (req, res, next) => {
|
router.all('/*', (req, res, next) => {
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
req.flash('danger', _('Need to be logged in to access restricted content'));
|
req.flash('danger', _('Need to be logged in to access restricted content'));
|
||||||
|
|
|
@ -44,6 +44,12 @@ class DuplicitNameError extends InteroperableError {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class DuplicitEmailError extends InteroperableError {
|
||||||
|
constructor(msg, data) {
|
||||||
|
super('DuplicitEmailError', msg, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const errorTypes = {
|
const errorTypes = {
|
||||||
InteroperableError,
|
InteroperableError,
|
||||||
NotLoggedInError,
|
NotLoggedInError,
|
||||||
|
@ -51,7 +57,8 @@ const errorTypes = {
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
LoopDetectedError,
|
LoopDetectedError,
|
||||||
ChildDetectedError,
|
ChildDetectedError,
|
||||||
DuplicitNameError
|
DuplicitNameError,
|
||||||
|
DuplicitEmailError
|
||||||
};
|
};
|
||||||
|
|
||||||
function deserialize(errorObj) {
|
function deserialize(errorObj) {
|
||||||
|
|
Loading…
Reference in a new issue