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 { .mt-button-row > button {
margin-right: 15px; margin-right: 15px;
} }
.mt-button-row > button:last-child { .mt-button-row > button:last-child {
margin-right: 0px; margin-right: 0px;
} }
.mt-form-status { .mt-form-status {
padding-top: 5px; padding-top: 5px;
padding-bottom: 5px; padding-bottom: 5px;
} }
.mt-action-links > a { .mt-action-links > a {
margin-right: 8px; margin-right: 8px;
} }
.mt-action-links > a:last-child { .mt-action-links > a:last-child {
margin-right: 0px; 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.state.entityId = parseInt(props.match.params.id);
} }
this.initFormState(); this.initForm();
this.hasChildren = false; this.hasChildren = false;
} }

View file

@ -8,6 +8,7 @@ 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';
import passwordValidator from '../../../shared/password-validator'; import passwordValidator from '../../../shared/password-validator';
import validators from '../../../shared/validators';
import { ModalDialog } from '../lib/bootstrap-components'; import { ModalDialog } from '../lib/bootstrap-components';
@translate() @translate()
@ -26,7 +27,13 @@ export default class CUD extends Component {
this.state.entityId = parseInt(props.match.params.id); 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; this.hasChildren = false;
} }
@ -36,7 +43,10 @@ export default class CUD extends Component {
@withAsyncErrorHandler @withAsyncErrorHandler
async loadFormValues() { 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() { componentDidMount() {
@ -46,7 +56,9 @@ export default class CUD extends Component {
this.populateFormValues({ this.populateFormValues({
username: '', username: '',
name: '', name: '',
email: '' email: '',
password: '',
password2: ''
}); });
} }
} }
@ -57,12 +69,11 @@ export default class CUD extends Component {
const username = state.getIn(['username', 'value']); const username = state.getIn(['username', 'value']);
const usernamePattern = /^[a-zA-Z0-9][a-zA-Z0-9_\-.]*$/;
const usernameServerValidation = state.getIn(['username', 'serverValidation']); const usernameServerValidation = state.getIn(['username', 'serverValidation']);
if (!username) { if (!username) {
state.setIn(['username', 'error'], t('User name must not be empty')); 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.')); 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) { } else if (!usernameServerValidation || usernameServerValidation.exists) {
state.setIn(['username', 'error'], t('The user name already exists in the system.')); 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.disableForm();
this.setFormStatusMessage('info', t('Saving user ...')); 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) { if (submitSuccessful) {
this.navigateToWithFlashMessage('/users', 'success', t('User saved')); this.navigateToWithFlashMessage('/users', 'success', t('User saved'));
@ -179,10 +192,11 @@ export default class CUD extends Component {
render() { render() {
const t = this.props.t; const t = this.props.t;
const edit = this.props.edit; const edit = this.props.edit;
const isAdmin = this.getFormValue('id') === 1;
return ( return (
<div> <div>
{edit && {edit && !isAdmin &&
<ModalDialog hidden={!this.isDelete()} title={t('Confirm deletion')} onCloseAsync={::this.hideDeleteModal} buttons={[ <ModalDialog hidden={!this.isDelete()} title={t('Confirm deletion')} onCloseAsync={::this.hideDeleteModal} buttons={[
{ label: t('No'), className: 'btn-primary', onClickAsync: ::this.hideDeleteModal }, { label: t('No'), className: 'btn-primary', onClickAsync: ::this.hideDeleteModal },
{ label: t('Yes'), className: 'btn-danger', onClickAsync: ::this.performDelete } { 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="username" label={t('User Name')}/>
<InputField id="name" label={t('Full Name')}/> <InputField id="name" label={t('Full Name')}/>
<InputField id="email" label={t('Email')}/> <InputField id="email" label={t('Email')}/>
<InputField id="password" label={t('Password')}/> <InputField id="password" label={t('Password')} type="password" />
<InputField id="password2" label={t('Repeat Password')}/> <InputField id="password2" label={t('Repeat Password')} type="password" />
<ButtonRow> <ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/> <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}/>} onClickAsync={::this.showDeleteModal}/>}
</ButtonRow> </ButtonRow>
</Form> </Form>

View file

@ -23,19 +23,5 @@ async function validateEmail(address, checkBlocked) {
}, resolve); }, resolve);
}); });
if (result !== 0) { return result;
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);
}
} }

View file

@ -5,9 +5,11 @@ const hasher = require('node-object-hash')();
const { enforce, filterObject } = require('../lib/helpers'); const { enforce, filterObject } = require('../lib/helpers');
const interoperableErrors = require('../shared/interoperable-errors'); const interoperableErrors = require('../shared/interoperable-errors');
const passwordValidator = require('../shared/password-validator')(); const passwordValidator = require('../shared/password-validator')();
const validators = require('../shared/validators');
const dtHelpers = require('../lib/dt-helpers'); const dtHelpers = require('../lib/dt-helpers');
const bcrypt = require('bcrypt-nodejs'); const tools = require('../lib/tools-async');
const tools = require('../lib/tools'); const Promise = require('bluebird');
const bcryptHash = Promise.promisify(require('bcrypt-nodejs').hash);
const allowedKeys = new Set(['username', 'name', 'email', 'password']); const allowedKeys = new Set(['username', 'name', 'email', 'password']);
@ -16,39 +18,89 @@ function hash(user) {
} }
async function getById(userId) { 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) { if (!user) {
throw new interoperableErrors.NotFoundError(); throw new interoperableErrors.NotFoundError();
} }
user.hash = hash(user); user.hash = hash(user);
delete(user.password);
return user; return user;
} }
async function getByUsername(username) { async function serverValidate(data) {
const user = await knex('users').select(['id', 'username', 'name', 'email']).where('username', username).first(); const result = {};
if (!user) {
throw new interoperableErrors.NotFoundError(); 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) { 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) {
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) { async function create(user) {
_validateAndPreprocess(user, true);
const userId = await knex('users').insert(filterObject(user, allowedKeys)); const userId = await knex('users').insert(filterObject(user, allowedKeys));
return userId; return userId;
} }
async function updateWithConsistencyCheck(user) { async function updateWithConsistencyCheck(user) {
_validateAndPreprocess(user, false);
await knex.transaction(async tx => { 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) { if (!user) {
throw new interoperableErrors.NotFoundError(); throw new interoperableErrors.NotFoundError();
} }
@ -58,41 +110,13 @@ async function updateWithConsistencyCheck(user) {
throw new interoperableErrors.ChangedError(); 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)); await tx('users').where('id', user.id).update(filterObject(user, allowedKeys));
}); });
} }
async function remove(userId) { async function remove(userId) {
// FIXME: enforce that userId is not the current user // FIXME: enforce that userId is not the current user
enforce(userId !== 1, 'Admin cannot be deleted');
await knex('users').where('id', userId).del(); await knex('users').where('id', userId).del();
} }
@ -103,5 +127,5 @@ module.exports = {
create, create,
hash, hash,
getById, getById,
getByUsername serverValidate
}; };

View file

@ -5,7 +5,6 @@ const router = require('../lib/router-async').create();
const _ = require('../lib/translate')._; const _ = require('../lib/translate')._;
const users = require('../models/users'); const users = require('../models/users');
const interoperableErrors = require('../shared/interoperable-errors'); const interoperableErrors = require('../shared/interoperable-errors');
const tools = require('../lib/tools-async');
router.all('/rest/*', (req, res, next) => { 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) => { router.postAsync('/rest/validate', async (req, res) => {
const data = {}; return res.json(await users.serverValidate(req.body));
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);
}); });
router.postAsync('/rest/usersTable', async (req, res) => { router.postAsync('/rest/usersTable', async (req, res) => {
return res.json(await users.listDTAjax(req.body)); return res.json(await users.listDTAjax(req.body));
}); });

View file

@ -1,75 +1,75 @@
exports.up = function(knex, Promise) { exports.up = function(knex, Promise) {
/* This is shows what it would look like when we specify the "users" table with Knex. /* 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. In some sense, this is probably the most complicated table we have in Mailtrain.
return knex.schema.hasTable('users')) return knex.schema.hasTable('users'))
.then(exists => { .then(exists => {
if (!exists) { if (!exists) {
return knex.schema.createTable('users', table => { return knex.schema.createTable('users', table => {
table.increments('id').primary(); table.increments('id').primary();
table.string('username').notNullable().defaultTo(''); table.string('username').notNullable();
table.string('password').notNullable().defaultTo(''); table.string('password').notNullable();
table.string('email').notNullable(); table.string('email').notNullable();
table.string('access_token', 40).index(); table.string('access_token', 40).index();
table.string('reset_token').index(); table.string('reset_token').index();
table.dateTime('reset_expire'); table.dateTime('reset_expire');
table.timestamp('created').defaultTo(knex.fn.now()); table.timestamp('created').defaultTo(knex.fn.now());
}) })
// INNODB tables have the limit of 767 bytes for an index. // 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 // 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. // 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` MODIFY `email` VARCHAR(255) CHARACTER SET utf8 NOT NULL')
.raw('ALTER TABLE `users` ADD UNIQUE KEY `email` (`email`)') .raw('ALTER TABLE `users` ADD UNIQUE KEY `email` (`email`)')
.raw('ALTER TABLE `users` ADD KEY `username` (`username`(191))') .raw('ALTER TABLE `users` ADD KEY `username` (`username`(191))')
.raw('ALTER TABLE `users` ADD KEY `check_reset` (`username`(191),`reset_token`,`reset_expire`)') .raw('ALTER TABLE `users` ADD KEY `check_reset` (`username`(191),`reset_token`,`reset_expire`)')
.then(() => knex('users').insert({ .then(() => knex('users').insert({
id: 1, id: 1,
username: 'admin', username: 'admin',
password: '$2a$10$FZV.tFT252o4iiHoZ9b2sOZOc.EBDOcY2.9HNCtNwshtSLf21mB1i', password: '$2a$10$FZV.tFT252o4iiHoZ9b2sOZOc.EBDOcY2.9HNCtNwshtSLf21mB1i',
email: 'hostmaster@sathyasai.org' 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. // 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. // 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') return knex('settings').where({key: 'db_schema_version'}).first('value')
.then(row => { .then(row => {
if (!row || Number(row.value) !== 29) { if (!row || Number(row.value) !== 29) {
throw new Error('Unsupported DB schema version: ' + row.value); 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 // 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) ). // Knex uses unsigned int (which is unsigned int(10) ).
.then(() => knex.schema .then(() => knex.schema
.raw('ALTER TABLE `users` MODIFY `id` int unsigned not null auto_increment') .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 `lists` MODIFY `id` int unsigned not null auto_increment')
.raw('ALTER TABLE `confirmations` MODIFY `list` int unsigned not null') .raw('ALTER TABLE `confirmations` MODIFY `list` int unsigned not null')
.raw('ALTER TABLE `custom_fields` 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 `importer` MODIFY `list` int unsigned not null')
.raw('ALTER TABLE `segments` 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 `triggers` MODIFY `list` int unsigned not null')
.raw('ALTER TABLE `custom_forms` MODIFY `list` int unsigned not null') .raw('ALTER TABLE `custom_forms` MODIFY `list` int unsigned not null')
) )
/* /*
Remaining foreign keys: Remaining foreign keys:
----------------------- -----------------------
links campaign campaigns id links campaign campaigns id
segment_rules segment segments id segment_rules segment segments id
import_failed import importer id import_failed import importer id
rss parent campaigns id rss parent campaigns id
attachments campaign campaigns id attachments campaign campaigns id
custom_forms_data form custom_forms id custom_forms_data form custom_forms id
report_template report_template report_templates id report_template report_template report_templates id
*/ */
}; };
exports.down = function(knex, Promise) { exports.down = function(knex, Promise) {
// return knex.schema.dropTable('users'); // return knex.schema.dropTable('users');
}; };

View file

@ -1,7 +1,10 @@
exports.up = function(knex, Promise) { exports.up = function(knex, Promise) {
return knex.schema.table('users', table => { 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) { 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
};