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

File diff suppressed because it is too large Load diff

View file

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

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

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

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

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