Release candidate of basic user management - currently only CRUD on users, no permission assignment.

This commit is contained in:
Tomas Bures 2017-06-29 23:22:33 +02:00
parent e7856bfb73
commit eb2287f6e9
10 changed files with 776 additions and 750 deletions

View file

@ -112,7 +112,12 @@ class InputField extends Component {
static propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
placeholder: PropTypes.string
placeholder: PropTypes.string,
type: PropTypes.string
}
static defaultProps = {
type: 'text'
}
static contextTypes = {
@ -125,8 +130,13 @@ class InputField extends Component {
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,
<input type="text" 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)}/>
);
}
}
@ -272,12 +282,10 @@ function withForm(target) {
isServerValidationRunning: false
});
inst.initFormState = function(serverValidationUrl, serverValidationAttrs) {
inst.initForm = function(settings) {
const state = this.state || {};
state.formState = cleanFormState;
if (serverValidationUrl) {
state.formStateServerValidation = { url: serverValidationUrl, attrs: serverValidationAttrs };
}
state.formSettings = settings;
this.state = state;
};
@ -375,13 +383,17 @@ function withForm(target) {
};
inst.validateForm = function(mutState) {
const serverValidation = this.state.formStateServerValidation;
const settings = this.state.formSettings;
if (!mutState.get('isServerValidationRunning') && serverValidation) {
if (!mutState.get('isServerValidationRunning') && settings.serverValidation) {
const payload = {};
let payloadNotEmpty = false;
for (const attr of serverValidation.attrs) {
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']);
@ -396,7 +408,7 @@ function withForm(target) {
if (payloadNotEmpty) {
mutState.set('isServerValidationRunning', true);
axios.post(serverValidation.url, payload)
axios.post(settings.serverValidation.url, payload)
.then(response => {
this.setState(previousState => ({
@ -420,6 +432,11 @@ function withForm(target) {
})
.catch(error => {
console.log('Ignoring unhandled error in "validateForm": ' + error);
this.setState(previousState => ({
formState: previousState.formState.set('isServerValidationRunning', false)
}));
scheduleValidateForm(this);
});
} else {
@ -509,7 +526,7 @@ function withForm(target) {
};
inst.isFormServerValidated = function() {
return this.state.formStateServerValidation.attrs.every(attr => this.state.formState.getIn(['data', attr, 'serverValidated']));
return this.state.formSettings.serverValidation.changed.every(attr => this.state.formState.getIn(['data', attr, 'serverValidated']));
};
inst.getFormStatusMessageText = function() {

View file

@ -18,3 +18,7 @@
.mt-action-links > a:last-child {
margin-right: 0px;
}
.form-horizontal .control-label {
display: block;
}

View file

@ -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;
}

View file

@ -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 (
<div>
{edit &&
{edit && !isAdmin &&
<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 }
@ -197,12 +211,12 @@ export default class CUD extends Component {
<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')}/>
<InputField id="password" label={t('Password')} type="password" />
<InputField id="password2" label={t('Repeat Password')} type="password" />
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>
{edit && <Button className="btn-danger" icon="remove" label={t('Delete User')}
{edit && !isAdmin && <Button className="btn-danger" icon="remove" label={t('Delete User')}
onClickAsync={::this.showDeleteModal}/>}
</ButtonRow>
</Form>

View file

@ -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;
}

View file

@ -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);
}
user.hash = hash(user);
const user = await query.first();
result.username = {
exists: !!user
};
}
return user;
if (data.email) {
result.email = {};
result.email.invalid = await tools.validateEmail(data.email) !== 0;
}
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
};

View file

@ -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));
});

View file

@ -7,8 +7,8 @@ exports.up = function(knex, Promise) {
if (!exists) {
return knex.schema.createTable('users', table => {
table.increments('id').primary();
table.string('username').notNullable().defaultTo('');
table.string('password').notNullable().defaultTo('');
table.string('username').notNullable();
table.string('password').notNullable();
table.string('email').notNullable();
table.string('access_token', 40).index();
table.string('reset_token').index();

View file

@ -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) {

9
shared/validators.js Normal file
View file

@ -0,0 +1,9 @@
'use strict';
function usernameValid(username) {
return /^[a-zA-Z0-9][a-zA-Z0-9_\-.]*$/.test(username);
}
module.exports = {
usernameValid
};