Release candidate of basic user management - currently only CRUD on users, no permission assignment.
This commit is contained in:
parent
e7856bfb73
commit
eb2287f6e9
10 changed files with 776 additions and 750 deletions
File diff suppressed because it is too large
Load diff
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
104
models/users.js
104
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
|
||||
};
|
|
@ -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));
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
};
|
|
@ -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
9
shared/validators.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
'use strict';
|
||||
|
||||
function usernameValid(username) {
|
||||
return /^[a-zA-Z0-9][a-zA-Z0-9_\-.]*$/.test(username);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
usernameValid
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue