diff --git a/libraries/default/InboxProcessors/UserInboxProcessor.php b/libraries/default/InboxProcessors/UserInboxProcessor.php index 074ce13..53312d5 100644 --- a/libraries/default/InboxProcessors/UserInboxProcessor.php +++ b/libraries/default/InboxProcessors/UserInboxProcessor.php @@ -1,5 +1,6 @@ 'E-mail must be valid' ]) ->notEmpty('first_name', 'A first name must be provided') - ->notEmpty('last_name', 'A last name must be provided'); + ->notEmpty('last_name', 'A last name must be provided') + ->add('password', 'password_complexity', [ + 'rule' => function($value, $context) { + if (!preg_match('/^((?=.*\d)|(?=.*\W+))(?![\n])(?=.*[A-Z])(?=.*[a-z]).*$|.{16,}/s', $value) || strlen($value) < 12) { + return false; + } + return true; + }, + 'message' => __('Invalid password. Passwords have to be either 16 character long or 12 character long with 3/4 special groups.') + ]); } public function create($requestData) { $this->validateRequestData($requestData); + $requestData['data']['password'] = (new DefaultPasswordHasher())->hash($requestData['data']['password']); $requestData['title'] = __('User account creation requested for {0}', $requestData['data']['email']); - return parent::create($requestData); + $creationResponse = parent::create($requestData); + $creationResponse['message'] = __('User account creation requested. Please wait for an admin to approve your account.'); + return $creationResponse; } public function getViewVariables($request) @@ -72,6 +85,11 @@ class RegistrationProcessor extends UserInboxProcessor implements GenericInboxPr 'username' => !empty($request['data']['username']) ? $request['data']['username'] : '', 'role_id' => !empty($request['data']['role_id']) ? $request['data']['role_id'] : '', 'disabled' => !empty($request['data']['disabled']) ? $request['data']['disabled'] : '', + + 'email' => !empty($request['data']['email']) ? $request['data']['email'] : '', + 'first_name' => !empty($request['data']['first_name']) ? $request['data']['first_name'] : '', + 'last_name' => !empty($request['data']['last_name']) ? $request['data']['last_name'] : '', + 'position' => !empty($request['data']['position']) ? $request['data']['position'] : '', ]); return [ 'dropdownData' => $dropdownData, @@ -82,6 +100,7 @@ class RegistrationProcessor extends UserInboxProcessor implements GenericInboxPr public function process($id, $requestData, $inboxRequest) { + $hashedPassword = $inboxRequest['data']['password']; if ($requestData['individual_id'] == -1) { $individual = $this->Users->Individuals->newEntity([ 'uuid' => $requestData['uuid'], @@ -101,6 +120,7 @@ class RegistrationProcessor extends UserInboxProcessor implements GenericInboxPr 'role_id' => $requestData['role_id'], 'disabled' => $requestData['disabled'], ]); + $user->set('password', $hashedPassword, ['setter' => false]); // ignore default password hashing as it has already been hashed $user = $this->Users->save($user); if ($user !== false) { diff --git a/libraries/default/InboxProcessors/templates/User/Registration.php b/libraries/default/InboxProcessors/templates/User/Registration.php index ebcc819..2eaffb0 100644 --- a/libraries/default/InboxProcessors/templates/User/Registration.php +++ b/libraries/default/InboxProcessors/templates/User/Registration.php @@ -1,99 +1,88 @@ element('genericElements/Form/genericForm', [ - 'entity' => $userEntity, - 'ajax' => false, - 'raw' => true, - 'data' => [ - 'description' => __('Create user account'), - 'model' => 'User', - 'fields' => [ - [ - 'field' => 'individual_id', - 'type' => 'dropdown', - 'label' => __('Associated individual'), - 'options' => $dropdownData['individual'], - ], - [ - 'field' => 'username', - 'autocomplete' => 'off', - ], - [ - 'field' => 'role_id', - 'type' => 'dropdown', - 'label' => __('Role'), - 'options' => $dropdownData['role'] - ], - [ - 'field' => 'disabled', - 'type' => 'checkbox', - 'label' => 'Disable' - ] +$combinedForm = $this->element('genericElements/Form/genericForm', [ + 'entity' => $userEntity, + 'ajax' => false, + 'raw' => true, + 'data' => [ + 'description' => __('Create user account'), + 'model' => 'User', + 'fields' => [ + [ + 'field' => 'individual_id', + 'type' => 'dropdown', + 'label' => __('Associated individual'), + 'options' => $dropdownData['individual'], ], - 'submit' => [ - 'action' => $this->request->getParam('action') - ] - ] - ]); - - $formIndividual = $this->element('genericElements/Form/genericForm', [ - 'entity' => $individualEntity, - 'ajax' => false, - 'raw' => true, - 'data' => [ - 'description' => __('Create individual'), - 'model' => 'Individual', - 'fields' => [ - [ - 'field' => 'email', - 'autocomplete' => 'off' - ], - [ - 'field' => 'uuid', - 'label' => 'UUID', - 'type' => 'uuid', - 'autocomplete' => 'off' - ], - [ - 'field' => 'first_name', - 'autocomplete' => 'off' - ], - [ - 'field' => 'last_name', - 'autocomplete' => 'off' - ], - [ - 'field' => 'position', - 'autocomplete' => 'off' - ], + [ + 'field' => 'username', + 'autocomplete' => 'off', + ], + [ + 'field' => 'role_id', + 'type' => 'dropdown', + 'label' => __('Role'), + 'options' => $dropdownData['role'] + ], + [ + 'field' => 'disabled', + 'type' => 'checkbox', + 'label' => 'Disable' ], - 'submit' => [ - 'action' => $this->request->getParam('action') - ] - ] - ]); - echo $this->Bootstrap->modal([ - 'title' => __('Register user'), - 'size' => 'lg', - 'type' => 'confirm', - 'bodyHtml' => sprintf('
%s
%s
', - $formUser, - $formIndividual - ), - 'confirmText' => __('Create user'), - 'confirmFunction' => 'submitRegistration' - ]); + sprintf('
%s
', __('Create individual')), + [ + 'field' => 'email', + 'autocomplete' => 'off' + ], + [ + 'field' => 'uuid', + 'label' => 'UUID', + 'type' => 'uuid', + 'autocomplete' => 'off' + ], + [ + 'field' => 'first_name', + 'autocomplete' => 'off' + ], + [ + 'field' => 'last_name', + 'autocomplete' => 'off' + ], + [ + 'field' => 'position', + 'autocomplete' => 'off' + ], + ], + 'submit' => [ + 'action' => $this->request->getParam('action') + ] + ] +]); + + +echo $this->Bootstrap->modal([ + 'title' => __('Register user'), + 'size' => 'lg', + 'type' => 'confirm', + 'bodyHtml' => sprintf( + '
%s
', + $combinedForm + ), + 'confirmText' => __('Create user'), + 'confirmFunction' => 'submitRegistration' +]); ?> \ No newline at end of file diff --git a/plugins/Tags/webroot/js/tagging.js b/plugins/Tags/webroot/js/tagging.js index 606ad21..bcd9c3d 100644 --- a/plugins/Tags/webroot/js/tagging.js +++ b/plugins/Tags/webroot/js/tagging.js @@ -119,8 +119,10 @@ function initSelect2Picker($select) { } return buildTag(state) } + const $modal = $select.closest('.modal') $select.select2({ + dropdownParent: $modal.length != 0 ? $modal.find('.modal-body') : $(document.body), placeholder: 'Pick a tag', tags: true, width: '100%', diff --git a/src/Controller/Component/ACLComponent.php b/src/Controller/Component/ACLComponent.php index 1376d96..5feae97 100644 --- a/src/Controller/Component/ACLComponent.php +++ b/src/Controller/Component/ACLComponent.php @@ -254,7 +254,7 @@ class ACLComponent extends Component */ public function setPublicInterfaces(): void { - $this->Authentication->allowUnauthenticated(['login']); + $this->Authentication->allowUnauthenticated(['login', 'register']); } private function checkAccessInternal($controller, $action, $soft): bool diff --git a/src/Controller/Component/CRUDComponent.php b/src/Controller/Component/CRUDComponent.php index 585eeba..64b5777 100644 --- a/src/Controller/Component/CRUDComponent.php +++ b/src/Controller/Component/CRUDComponent.php @@ -90,7 +90,7 @@ class CRUDComponent extends Component $this->Controller->set('taggingEnabled', true); $this->setAllTags(); } - $filters = !empty($this->Controller->filters) ? $this->Controller->filters : []; + $filters = !empty($this->Controller->filterFields) ? $this->Controller->filterFields : []; $this->Controller->set('filters', $filters); $this->Controller->viewBuilder()->setLayout('ajax'); $this->Controller->render('/genericTemplates/filters'); diff --git a/src/Controller/Component/Navigation/sidemenu.php b/src/Controller/Component/Navigation/sidemenu.php index e56ac7f..16dd350 100644 --- a/src/Controller/Component/Navigation/sidemenu.php +++ b/src/Controller/Component/Navigation/sidemenu.php @@ -63,6 +63,11 @@ class Sidemenu { 'icon' => $this->iconTable['UserSettings'], 'url' => '/user-settings/index', ], + 'LocalTools.index' => [ + 'label' => __('Local Tools'), + 'icon' => $this->iconTable['LocalTools'], + 'url' => '/localTools/index', + ], 'Messages' => [ 'label' => __('Messages'), 'icon' => $this->iconTable['Inbox'], @@ -87,11 +92,6 @@ class Sidemenu { 'icon' => $this->iconTable['MetaTemplates'], 'url' => '/metaTemplates/index', ], - 'LocalTools.index' => [ - 'label' => __('Local Tools'), - 'icon' => $this->iconTable['LocalTools'], - 'url' => '/localTools/index', - ], 'Tags.index' => [ 'label' => __('Tags'), 'icon' => $this->iconTable['Tags'], diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index 2205f08..d2235a6 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -6,6 +6,8 @@ use Cake\Utility\Hash; use Cake\Utility\Text; use Cake\ORM\TableRegistry; use \Cake\Database\Expression\QueryExpression; +use Cake\Http\Exception\UnauthorizedException; +use Cake\Core\Configure; class UsersController extends AppController { @@ -160,19 +162,27 @@ class UsersController extends AppController public function register() { - $this->InboxProcessors = TableRegistry::getTableLocator()->get('InboxProcessors'); - $processor = $this->InboxProcessors->getProcessor('User', 'Registration'); - $data = [ - 'origin' => '127.0.0.1', - 'comment' => 'Hi there!, please create an account', - 'data' => [ - 'username' => 'foobar', - 'email' => 'foobar@admin.test', - 'first_name' => 'foo', - 'last_name' => 'bar', - ], - ]; - $processorResult = $processor->create($data); - return $processor->genHTTPReply($this, $processorResult, ['controller' => 'Inbox', 'action' => 'index']); + if (empty(Configure::read('Cerebrate')['security.registration.self-registration'])) { + throw new UnauthorizedException(__('User self-registration is not open.')); + } + if ($this->request->is('post')) { + $data = $this->request->getData(); + $this->InboxProcessors = TableRegistry::getTableLocator()->get('InboxProcessors'); + $processor = $this->InboxProcessors->getProcessor('User', 'Registration'); + $data = [ + 'origin' => $this->request->clientIp(), + 'comment' => '-no comment-', + 'data' => [ + 'username' => $data['username'], + 'email' => $data['email'], + 'first_name' => $data['first_name'], + 'last_name' => $data['last_name'], + 'password' => $data['password'], + ], + ]; + $processorResult = $processor->create($data); + return $processor->genHTTPReply($this, $processorResult, ['controller' => 'Inbox', 'action' => 'index']); + } + $this->viewBuilder()->setLayout('login'); } } diff --git a/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php b/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php index 97641d5..25365de 100644 --- a/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php +++ b/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php @@ -263,6 +263,16 @@ class CerebrateSettingsProvider extends BaseSettingsProvider ] ], 'Security' => [ + 'Registration' => [ + 'Registration' => [ + 'security.registration.self-registration' => [ + 'name' => __('Allow self-registration'), + 'type' => 'boolean', + 'description' => __('Enable the self-registration feature where user can request account creation. Admin can view the request and accept it in the application inbox.'), + 'default' => false, + ], + ] + ], 'Development' => [ 'Debugging' => [ 'debug' => [ diff --git a/templates/Users/login.php b/templates/Users/login.php index 7cc35d0..d5cab0e 100644 --- a/templates/Users/login.php +++ b/templates/Users/login.php @@ -1,53 +1,58 @@ '; - echo sprintf( - '
%s
', - $this->Html->image('logo-purple.png', [ - 'alt' => __('Cerebrate logo'), - 'width' => 100, 'height' => 100, - 'style' => ['filter: drop-shadow(4px 4px 4px #924da666);'] - ]) - ); - echo sprintf('

%s

', __('Sign in')); - $template = [ - 'inputContainer' => '
{{content}}
', - 'formGroup' => '{{input}}{{label}}', - 'submitContainer' => '
{{content}}
', - ]; - $this->Form->setTemplates($template); - echo $this->Form->create(null, ['url' => ['controller' => 'users', 'action' => 'login']]); - echo $this->Form->control('username', ['label' => 'Username', 'class' => 'form-control mb-2', 'placeholder' => __('Username')]); - echo $this->Form->control('password', ['type' => 'password', 'label' => 'Password', 'class' => 'form-control mb-3', 'placeholder' => __('Password')]); - echo $this->Form->control(__('Submit'), ['type' => 'submit', 'class' => 'btn btn-primary']); - echo $this->Form->end(); - - if (!empty(Configure::read('keycloak'))) { - echo sprintf('

%s
', __('Or')); - echo $this->Form->create(null, [ - 'url' => Cake\Routing\Router::url([ - 'prefix' => false, - 'plugin' => 'ADmad/SocialAuth', - 'controller' => 'Auth', - 'action' => 'login', - 'provider' => 'keycloak', - '?' => ['redirect' => $this->request->getQuery('redirect')] - ]), - ]); - echo $this->Bootstrap->button([ - 'type' => 'submit', - 'text' => __('Login with Keycloak'), - 'variant' => 'secondary', - 'class' => ['d-block', 'w-100'], - 'image' => [ - 'path' => '/img/keycloak_logo.png', - 'alt' => 'Keycloak' - ] - ]); - echo $this->Form->end(); - } - echo ''; - +use Cake\Core\Configure; ?> - + +
+ %s
', + $this->Html->image('logo-purple.png', [ + 'alt' => __('Cerebrate logo'), + 'width' => 100, 'height' => 100, + 'style' => ['filter: drop-shadow(4px 4px 4px #924da666);'] + ]) + ); + echo sprintf('

%s

', __('Sign In')); + $template = [ + 'inputContainer' => '
{{content}}
', + 'formGroup' => '{{input}}{{label}}', + 'submitContainer' => '
{{content}}
', + ]; + $this->Form->setTemplates($template); + echo $this->Form->create(null, ['url' => ['controller' => 'users', 'action' => 'login']]); + echo $this->Form->control('username', ['label' => 'Username', 'class' => 'form-control mb-2', 'placeholder' => __('Username')]); + echo $this->Form->control('password', ['type' => 'password', 'label' => 'Password', 'class' => 'form-control mb-3', 'placeholder' => __('Password')]); + echo $this->Form->control(__('Login'), ['type' => 'submit', 'class' => 'btn btn-primary']); + echo $this->Form->end(); + if (!empty(Configure::read('Cerebrate')['security.registration.self-registration'])) { + echo '
'; + echo sprintf('%s %s', __('Doesn\'t have an account?'), __('Sign up')); + echo '
'; + } + + if (!empty(Configure::read('keycloak'))) { + echo sprintf('

%s
', __('Or')); + echo $this->Form->create(null, [ + 'url' => Cake\Routing\Router::url([ + 'prefix' => false, + 'plugin' => 'ADmad/SocialAuth', + 'controller' => 'Auth', + 'action' => 'login', + 'provider' => 'keycloak', + '?' => ['redirect' => $this->request->getQuery('redirect')] + ]), + ]); + echo $this->Bootstrap->button([ + 'type' => 'submit', + 'text' => __('Login with Keycloak'), + 'variant' => 'secondary', + 'class' => ['d-block', 'w-100'], + 'image' => [ + 'path' => '/img/keycloak_logo.png', + 'alt' => 'Keycloak' + ] + ]); + echo $this->Form->end(); + } + ?> + \ No newline at end of file diff --git a/templates/Users/register.php b/templates/Users/register.php new file mode 100644 index 0000000..dd1cba4 --- /dev/null +++ b/templates/Users/register.php @@ -0,0 +1,45 @@ + + +
+ %s
', + $this->Html->image('logo-purple.png', [ + 'alt' => __('Cerebrate logo'), + 'width' => 100, 'height' => 100, + 'style' => ['filter: drop-shadow(4px 4px 4px #924da666);'] + ]) + ); + echo sprintf('

%s

', __('Sign up')); + $template = [ + 'inputContainer' => '
{{content}}
', + 'formGroup' => '{{label}}{{input}}', + 'submitContainer' => '
{{content}}
', + 'label' => '' + ]; + $this->Form->setTemplates($template); + echo $this->Form->create(null, ['url' => ['controller' => 'users', 'action' => 'register']]); + + echo $this->Form->control('username', ['label' => __('Username'), 'class' => 'form-control mb-2']); + echo $this->Form->control('email', ['label' => __('E-mail Address'), 'class' => 'form-control mb-3']); + + echo '
'; + echo '
'; + echo $this->Form->control('first_name', ['label' => __('First Name'), 'class' => 'form-control']); + echo '
'; + echo '
'; + echo $this->Form->control('last_name', ['label' => __('Last Name'), 'class' => 'form-control mb-2']); + echo '
'; + echo '
'; + + echo $this->Form->control('password', ['type' => 'password', 'label' => __('Password'), 'class' => 'form-control mb-4']); + + echo $this->Form->control(__('Sign up'), ['type' => 'submit', 'class' => 'btn btn-primary']); + echo '
'; + echo sprintf('%s %s', __('Have an account?'), __('Sign in')); + echo '
'; + echo $this->Form->end(); + ?> + \ No newline at end of file diff --git a/templates/element/genericElements/IndexTable/headers.php b/templates/element/genericElements/IndexTable/headers.php index 8366449..b949d12 100644 --- a/templates/element/genericElements/IndexTable/headers.php +++ b/templates/element/genericElements/IndexTable/headers.php @@ -21,9 +21,14 @@ } } + if (!empty($header['element']) && $header['element'] === 'selector') { + $columnName = 'row-selector'; + } else { + $columnName = h(\Cake\Utility\Inflector::variable(!empty($header['name']) ? $header['name'] : \Cake\Utility\Inflector::humanize($header['data_path']))); + } $headersHtml .= sprintf( '%s', - h(\Cake\Utility\Inflector::variable(!empty($header['name']) ? $header['name'] : \Cake\Utility\Inflector::humanize($header['data_path']))), + $columnName, $header_data ); } diff --git a/templates/element/genericElements/ListTopBar/group_table_action/hiddenColumns.php b/templates/element/genericElements/ListTopBar/group_table_action/hiddenColumns.php index 5d0ce68..9714be4 100644 --- a/templates/element/genericElements/ListTopBar/group_table_action/hiddenColumns.php +++ b/templates/element/genericElements/ListTopBar/group_table_action/hiddenColumns.php @@ -4,6 +4,9 @@ $tableSettings['hidden_column'] = $tableSettings['hidden_column'] ?? []; $availableColumnsHtml = ''; $availableColumns = []; foreach ($table_data['fields'] as $field) { + if (!empty($field['element']) && $field['element'] === 'selector') { + continue; + } $fieldName = !empty($field['name']) ? $field['name'] : \Cake\Utility\Inflector::humanize($field['data_path']); $isVisible = !in_array(h(\Cake\Utility\Inflector::variable($fieldName)), $tableSettings['hidden_column']); $availableColumns[] = $fieldName; @@ -62,6 +65,7 @@ echo $availableColumnsHtml; } $(document).ready(function() { + addSupportOfNestedDropdown(); const $form = $('form.visible-column-form') const $checkboxes = $form.find('input').not(':checked') const $dropdownMenu = $form.closest('.dropdown') diff --git a/templates/genericTemplates/filters.php b/templates/genericTemplates/filters.php index d51a0b2..bc9698d 100644 --- a/templates/genericTemplates/filters.php +++ b/templates/genericTemplates/filters.php @@ -1,6 +1,13 @@ $fieldName, + ]; +}, $filters); + $filteringForm = $this->Bootstrap->table( [ 'small' => true, @@ -9,19 +16,36 @@ $filteringForm = $this->Bootstrap->table( 'tableClass' => ['indexFilteringTable'], ], [ - 'fields' => [ - __('Field'), - __('Operator'), - [ - 'labelHtml' => sprintf('%s %s', - __('Value'), - sprintf('', __('Supports strict matches and LIKE matches with the `%` character. Example: `%.com`')) - ) + 'fields' => [ + [ + 'key' => 'fieldname', 'label' => __('Field'), 'formatter' => function ($field, $row) { + return sprintf('%s', h($field), h($field)); + } + ], + [ + 'key' => 'operator', 'label' => __('Operator'), 'formatter' => function ($field, $row) { + $options = [ + sprintf('', '=', '='), + sprintf('', '!=', '!='), + ]; + return sprintf('', implode('', $options)); + } + ], + [ + 'key' => 'value', + 'labelHtml' => sprintf( + '%s %s', + __('Value'), + sprintf('', __('Supports strict matches and LIKE matches with the `%` character. Example: `%.com`')) + ), + 'formatter' => function ($field, $row) { + return sprintf(''); + } + ], ], - __('Action') - ], - 'items' => [] -]); + 'items' => $tableItems + ] +); if ($taggingEnabled) { $helpText = $this->Bootstrap->genNode('sup', [ @@ -40,7 +64,6 @@ if ($taggingEnabled) { } $modalBody = sprintf('%s%s', $filteringForm, $filteringTags); - echo $this->Bootstrap->modal([ 'title' => __('Filtering options for {0}', Inflector::singularize($this->request->getParam('controller'))), 'size' => 'lg', @@ -69,7 +92,9 @@ echo $this->Bootstrap->modal([ if (rowData['operator'] == '!=') { fullFilter += ' !=' } - activeFilters[fullFilter] = rowData['value'] + if (rowData['value'].length > 0) { + activeFilters[fullFilter] = rowData['value'] + } }) $select = modalObject.$modal.find('select.tag-input') activeFilters['filteringTags'] = $select.select2('data').map(tag => tag.text) @@ -85,8 +110,6 @@ echo $this->Bootstrap->modal([ function initFilteringTable($filteringTable) { const $controlRow = $filteringTable.find('#controlRow') - $filteringTable.find('tbody').empty() - addControlRow($filteringTable) const randomValue = getRandomValue() const activeFilters = Object.assign({}, $(`#toggleFilterButton-${randomValue}`).data('activeFilters')) const tags = activeFilters['filteringTags'] !== undefined ? Object.assign({}, activeFilters)['filteringTags'] : [] @@ -100,7 +123,7 @@ echo $this->Bootstrap->modal([ } else if (fieldParts.length > 2) { console.error('Field contains multiple spaces. ' + field) } - addFilteringRow($filteringTable, field, value, operator) + setFilteringValues($filteringTable, field, value, operator) } $select = $filteringTable.closest('.modal-body').find('select.tag-input') let passedTags = [] @@ -118,97 +141,17 @@ echo $this->Bootstrap->modal([ .trigger('change') } - function addControlRow($filteringTable) { - const availableFilters = ; - const $selectField = $('').addClass('fieldOperator form-select form-select-sm') - .append([ - $('