Merge branch 'develop'

pull/122/head
iglocska 2022-12-13 09:47:54 +01:00
commit c4170c8354
No known key found for this signature in database
GPG Key ID: BEA224F1FEF113AC
73 changed files with 1646 additions and 319 deletions

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
use Migrations\AbstractMigration;
final class InboxSeverity extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function change(): void
{
$exists = $this->table('inbox')->hasColumn('severity');
if (!$exists) {
$this->table('inbox')
->addColumn('severity', 'integer', [
'null' => false,
'default' => 0,
'signed' => false,
'length' => 10,
])
->renameColumn('comment', 'message')
->removeColumn('description')
->update();
$this->table('outbox')
->addColumn('severity', 'integer', [
'null' => false,
'default' => 0,
'signed' => false,
'length' => 10,
])
->renameColumn('comment', 'message')
->removeColumn('description')
->update();
}
}
}

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
use Migrations\AbstractMigration;
use Phinx\Db\Adapter\MysqlAdapter;
class MetaFieldSaneDefault extends AbstractMigration
{
public $autoId = false; // turn off automatic `id` column create. We want it to be `int(10) unsigned`
public function change()
{
$exists = $this->table('meta_template_fields')->hasColumn('sane_default');
if (!$exists) {
$this->table('meta_template_fields')
->addColumn('sane_default', 'text', [
'default' => null,
'null' => true,
'limit' => MysqlAdapter::TEXT_LONG,
'comment' => 'List of sane default values to be proposed',
])
->addColumn('values_list', 'text', [
'default' => null,
'null' => true,
'limit' => MysqlAdapter::TEXT_LONG,
'comment' => 'List of values that have to be used',
])
->update();
}
}
}

View File

@ -19,9 +19,12 @@ class GenericInboxProcessor
protected $validator;
protected $processingTemplate = '/genericTemplates/confirm';
protected $processingTemplatesDirectory = ROOT . '/libraries/default/InboxProcessors/templates';
protected $defaultSeverity;
protected $severity;
public function __construct($registerActions=false) {
$this->Inbox = TableRegistry::getTableLocator()->get('Inbox');
$this->defaultSeverity = $this->Inbox::SEVERITY_INFO;
if ($registerActions) {
$this->registerActionInProcessor();
}
@ -54,6 +57,10 @@ class GenericInboxProcessor
{
return $this->description ?? '';
}
public function getSeverity()
{
return $this->severity ?? $this->defaultSeverity;
}
protected function getProcessingTemplatePath()
{
@ -76,8 +83,9 @@ class GenericInboxProcessor
$builder = new ViewBuilder();
$builder->disableAutoLayout()
->setClassName('Monad')
->setTemplate($processingTemplate);
$view = $builder->build($viewVariables);
->setTemplate($processingTemplate)
->setVars($viewVariables);
$view = $builder->build();
$view->setRequest($serverRequest);
return $view->render();
}
@ -191,7 +199,7 @@ class GenericInboxProcessor
{
$requestData['scope'] = $this->scope;
$requestData['action'] = $this->action;
$requestData['description'] = $this->description;
$requestData['severity'] = $this->getSeverity();
$request = $this->generateRequest($requestData);
$savedRequest = $this->Inbox->createEntry($request);
return $this->genActionResult(

View File

@ -179,6 +179,7 @@ class IncomingConnectionRequestProcessor extends LocalToolInboxProcessor impleme
public function __construct() {
parent::__construct();
$this->severity = $this->Inbox::SEVERITY_WARNING;
$this->description = __('Handle Phase I of inter-connection when another cerebrate instance performs the request.');
}
@ -291,6 +292,7 @@ class AcceptedRequestProcessor extends LocalToolInboxProcessor implements Generi
public function __construct() {
parent::__construct();
$this->severity = $this->Inbox::SEVERITY_WARNING;
$this->description = __('Handle Phase II of inter-connection when initial request has been accepted by the remote cerebrate.');
}
@ -367,6 +369,7 @@ class DeclinedRequestProcessor extends LocalToolInboxProcessor implements Generi
public function __construct() {
parent::__construct();
$this->severity = $this->Inbox::SEVERITY_WARNING;
$this->description = __('Handle Phase II of MISP inter-connection when initial request has been declined by the remote cerebrate.');
}

View File

@ -0,0 +1,97 @@
<?php
use Cake\ORM\TableRegistry;
require_once(ROOT . DS . 'libraries' . DS . 'default' . DS . 'InboxProcessors' . DS . 'GenericInboxProcessor.php');
class NotificationInboxProcessor extends GenericInboxProcessor
{
protected $scope = 'Notification';
protected $action = 'not-specified'; //overriden when extending
protected $description = ''; // overriden when extending
protected $registeredActions = [
'DataChange'
];
public function __construct($loadFromAction=false) {
parent::__construct($loadFromAction);
}
public function create($requestData)
{
return parent::create($requestData);
}
}
class DataChangeProcessor extends NotificationInboxProcessor implements GenericInboxProcessorActionI {
public $action = 'DataChange';
protected $description;
public function __construct() {
parent::__construct();
$this->description = __('Notify when data has been changed');
$this->severity = $this->Inbox::SEVERITY_PRIMARY;
}
protected function addValidatorRules($validator)
{
return $validator
->requirePresence('original', 'changed')
->isArray('original', __('The `original` data must be provided'))
->isArray('changed', __('The `changed` data must be provided'));
}
public function create($requestData) {
$this->validateRequestData($requestData);
return parent::create($requestData);
}
public function getViewVariables($request)
{
$request = $this->__decodeMoreFields($request);
return [
'data' => [
'original' => $request->data['original'],
'changed' => $request->data['changed'],
'summaryTemplate' => $request->data['summaryTemplate'],
'summaryMessage' => $request->data['summaryMessage'],
'entityType' => $request->data['entityType'],
'entityDisplayField' => $request->data['entityDisplayField'],
'entityViewURL' => $request->data['entityViewURL'],
],
];
}
public function process($id, $requestData, $inboxRequest)
{
$this->discard($id, $requestData);
return $this->genActionResult(
[],
true,
__('Notification acknowledged'),
[]
);
}
public function discard($id, $requestData)
{
return parent::discard($id, $requestData);
}
private function __decodeMoreFields($request)
{
$decodedRequest = $request;
$decodedRequest->data['original'] = $this->__decodeMoreChangedFields($request->data['original']);
$decodedRequest->data['changed'] = $this->__decodeMoreChangedFields($request->data['changed']);
return $decodedRequest;
}
private function __decodeMoreChangedFields(array $fieldData): array
{
array_walk($fieldData, function(&$fieldValue, $fieldName) {
if ($fieldName === 'meta_fields') {
$fieldValue = json_decode($fieldValue, true);
}
});
return $fieldData;
}
}

View File

@ -28,6 +28,7 @@ class DataExchangeProcessor extends SynchronisationInboxProcessor implements Gen
public function __construct() {
parent::__construct();
$this->severity = $this->Inbox::SEVERITY_WARNING;
$this->description = __('Handle exchange of data between two cerebrate instances');
$this->Users = TableRegistry::getTableLocator()->get('Users');
}

View File

@ -31,6 +31,7 @@ class RegistrationProcessor extends UserInboxProcessor implements GenericInboxPr
public function __construct() {
parent::__construct();
$this->severity = $this->Inbox::SEVERITY_WARNING;
$this->description = __('Handle user account for this cerebrate instance');
}
@ -72,8 +73,28 @@ class RegistrationProcessor extends UserInboxProcessor implements GenericInboxPr
]),
'individual' => [-1 => __('-- New individual --')] + $this->Users->Individuals->find('list', [
'sort' => ['email' => 'asc']
])->toArray()
])->toArray(),
'organisation' => $this->Users->Organisations->find('list', [
'sort' => ['email' => 'asc']
])->toArray(),
];
$defaultRole = $this->Users->Roles->find()->select(['id'])->where(['is_default' => true])->first()->toArray();
$conditions = [];
if (!empty($request['data']['org_uuid'])) {
$conditions['uuid'] = $request['data']['org_uuid'];
} else if (!empty($request['data']['org_name'])) {
$conditions['name'] = $request['data']['org_name'];
}
$desiredOrg = null;
if (!empty($conditions)) {
$desiredOrg = $this->Users->Organisations->find()
->where($conditions)
->first()
->toArray();
}
$individualEntity = $this->Users->Individuals->newEntity([
'email' => !empty($request['data']['email']) ? $request['data']['email'] : '',
'first_name' => !empty($request['data']['first_name']) ? $request['data']['first_name'] : '',
@ -85,6 +106,7 @@ 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'] : '',
'org_id' => !empty($desiredOrg) ? $desiredOrg['id'] : '',
'email' => !empty($request['data']['email']) ? $request['data']['email'] : '',
'first_name' => !empty($request['data']['first_name']) ? $request['data']['first_name'] : '',
@ -94,7 +116,12 @@ class RegistrationProcessor extends UserInboxProcessor implements GenericInboxPr
return [
'dropdownData' => $dropdownData,
'userEntity' => $userEntity,
'individualEntity' => $individualEntity
'individualEntity' => $individualEntity,
'desiredOrganisation' => [
'org_name' => $request['data']['org_name'],
'org_uuid' => $request['data']['org_uuid'],
],
'defaultRole' => $defaultRole,
];
}
@ -119,6 +146,7 @@ class RegistrationProcessor extends UserInboxProcessor implements GenericInboxPr
'password' => '~PASSWORD_TO_BE_REPLACED~',
'role_id' => $requestData['role_id'],
'disabled' => $requestData['disabled'],
'organisation_id' => $requestData['org_id'],
]);
$user->set('password', $hashedPassword, ['setter' => false]); // ignore default password hashing as it has already been hashed
$user = $this->Users->save($user);

View File

@ -0,0 +1,59 @@
<?php
if (!empty($data['summary'])) {
$changedSummary = h($data['summary']);
} else if (!empty($data['summaryTemplate']) && !empty($data['summaryMessage'])) {
$changedSummary = __(
sprintf('%s. %s.', h($data['summaryTemplate']), h($data['summaryMessage'])),
h($data['entityType']),
sprintf(
'<a href="%s" target="_blank">%s</a>',
h($data['entityViewURL']),
h($data['entityDisplayField'])
)
);
} else {
$changedSummary = '';
}
$form = $this->element('genericElements/Form/genericForm', [
'entity' => null,
'ajax' => false,
'raw' => true,
'data' => [
'model' => 'Inbox',
'fields' => [],
'submit' => [
'action' => $this->request->getParam('action')
]
]
]);
echo $this->Bootstrap->modal([
'title' => __('Acknowledge notification'),
'size' => 'xl',
'type' => 'confirm',
'bodyHtml' => sprintf(
'<div class="d-none">%s</div>
<div class="container">
<div class="row">
<p>%s</p>
<div class="col">%s</div>
<div class="col">%s</div>
</div>
</div>',
$form,
$changedSummary,
$this->Bootstrap->card([
'headerText' => __('Original values'),
'bodyHTML' => $this->element('genericElements/SingleViews/Fields/jsonField', ['field' => ['raw' => $data['original']]])
]),
$this->Bootstrap->card([
'headerText' => __('Changed values'),
'bodyHTML' => $this->element('genericElements/SingleViews/Fields/jsonField', ['field' => ['raw' => $data['changed']]])
])
),
'confirmText' => __('Acknowledge & Discard'),
'confirmIcon' => 'check',
]);
?>
</div>

View File

@ -1,10 +1,23 @@
<?php
$infoAlert = $this->Bootstrap->alert([
'variant' => 'info',
'html' => sprintf('
<ul>
<li>%s: <strong>%s</strong></li>
<li>%s: <strong>%s</strong></li>
</ul>',
__('Requested Organisation name'), $desiredOrganisation['org_name'],
__('Requested Organisation UUID'), $desiredOrganisation['org_uuid']
),
'dismissible' => false,
]);
$combinedForm = $this->element('genericElements/Form/genericForm', [
'entity' => $userEntity,
'ajax' => false,
'raw' => true,
'data' => [
'description' => __('Create user account'),
'descriptionHtml' => __('Create user account') . sprintf('<div class="mt-2">%s</div>', $infoAlert),
'model' => 'User',
'fields' => [
[
@ -17,19 +30,26 @@ $combinedForm = $this->element('genericElements/Form/genericForm', [
'field' => 'username',
'autocomplete' => 'off',
],
[
'field' => 'org_id',
'type' => 'dropdown',
'label' => __('Associated organisation'),
'options' => $dropdownData['organisation'],
],
[
'field' => 'role_id',
'type' => 'dropdown',
'label' => __('Role'),
'options' => $dropdownData['role']
'options' => $dropdownData['role'],
'default' => $defaultRole,
],
[
'field' => 'disabled',
'type' => 'checkbox',
'label' => 'Disable'
],
sprintf('<div class="pb-2 fs-4">%s</div>', __('Create individual')),
'<div class="individual-container">',
sprintf('<div class="pb-2 fs-4">%s</div>', __('Create a new individual')),
[
'field' => 'email',
'autocomplete' => 'off'
@ -52,6 +72,7 @@ $combinedForm = $this->element('genericElements/Form/genericForm', [
'field' => 'position',
'autocomplete' => 'off'
],
'</div>',
],
'submit' => [
'action' => $this->request->getParam('action')
@ -86,15 +107,19 @@ echo $this->Bootstrap->modal([
}
$(document).ready(function() {
$('div.user-container #individual_id-field').change(function() {
if ($(this).val() == -1) {
$('div.individual-container').show()
} else {
$('div.individual-container').hide()
}
$('form #individual_id-field').change(function() {
toggleIndividualContainer($(this).val() == -1)
})
})
function toggleIndividualContainer(show) {
if (show) {
$('div.individual-container').show()
} else {
$('div.individual-container').hide()
}
}
function getFormData(form) {
return Object.values(form).reduce((obj, field) => {
if (field.type === 'checkbox') {
@ -105,11 +130,4 @@ echo $this->Bootstrap->modal([
return obj
}, {})
}
</script>
<style>
div.individual-container>div,
div.user-container>div {
font-size: 1.5rem;
}
</style>
</script>

View File

@ -70,6 +70,7 @@ class ResendFailedMessageProcessor extends BroodsOutboxProcessor implements Gene
public function __construct() {
parent::__construct();
$this->description = __('Handle re-sending messages that failed to be received from other cerebrate instances.');
$this->severity = $this->Inbox::SEVERITY_WARNING;
$this->Broods = TableRegistry::getTableLocator()->get('Broods');
$this->LocalTools = \Cake\ORM\TableRegistry::getTableLocator()->get('LocalTools');
}

View File

@ -20,9 +20,14 @@ class GenericOutboxProcessor
protected $validator;
protected $processingTemplate = '/genericTemplates/confirm';
protected $processingTemplatesDirectory = ROOT . '/libraries/default/OutboxProcessors/templates';
protected $defaultSeverity;
protected $severity;
public function __construct($registerActions=false) {
$this->Outbox = TableRegistry::getTableLocator()->get('Outbox');
$this->Inbox = TableRegistry::getTableLocator()->get('Inbox');
$this->defaultSeverity = $this->Inbox::SEVERITY_INFO;
if ($registerActions) {
$this->registerActionInProcessor();
}
@ -55,6 +60,10 @@ class GenericOutboxProcessor
{
return $this->description ?? '';
}
public function getSeverity()
{
return $this->severity ?? $this->defaultSeverity;
}
protected function getProcessingTemplatePath()
{
@ -77,8 +86,9 @@ class GenericOutboxProcessor
$builder = new ViewBuilder();
$builder->disableAutoLayout()
->setClassName('Monad')
->setTemplate($processingTemplate);
$view = $builder->build($viewVariables);
->setTemplate($processingTemplate)
->setVars($viewVariables);
$view = $builder->build();
$view->setRequest($serverRequest);
return $view->render();
}
@ -193,7 +203,7 @@ class GenericOutboxProcessor
$user_id = Router::getRequest()->getSession()->read('Auth.id');
$requestData['scope'] = $this->scope;
$requestData['action'] = $this->action;
$requestData['description'] = $this->description;
$requestData['severity'] = $this->getSeverity();
$requestData['user_id'] = $user_id;
$request = $this->generateRequest($requestData);
$savedRequest = $this->Outbox->createEntry($request);

View File

@ -189,6 +189,8 @@ class TagBehavior extends Behavior
{
if (is_object($tag)) {
return $tag->name;
} else if (is_array($tag)) {
return $tag['name'];
} else {
return trim($tag);
}

View File

@ -11,7 +11,7 @@ use Cake\Console\Command;
use Cake\Console\Arguments;
use Cake\Console\ConsoleIo;
use Cake\Console\ConsoleOptionParser;
use Cake\Filesystem\File;
use Cake\Filesystem\Folder;
use Cake\Utility\Hash;
use Cake\Utility\Text;
use Cake\Validation\Validator;
@ -22,7 +22,7 @@ class SummaryCommand extends Command
{
protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser
{
$parser->setDescription('Create a summary for data associated to the passed nationality that has been modified.');
$parser->setDescription('Create a summary for data associated to the passed nationality that has been modified. Summaries will be printed out in STDIN and written in individual `txt` files.');
$parser->addArgument('nationality', [
'short' => 'n',
'help' => 'The organisation nationality.',
@ -33,6 +33,11 @@ class SummaryCommand extends Command
'help' => 'The amount of days to look back in the logs',
'default' => 7
]);
$parser->addOption('output', [
'short' => 'o',
'help' => 'The destination folder where to write the files',
'default' => '/tmp'
]);
return $parser;
}
@ -49,15 +54,16 @@ class SummaryCommand extends Command
}
foreach ($nationalities as $nationality) {
$this->io->out(sprintf('Nationality: %s', $nationality));
$this->_collectChangedForNationality($nationality, $days);
$this->_collectChangedForNationality($nationality, $days, $args->getOption('output'));
$this->io->out($io->nl(2));
$this->io->hr();
}
}
protected function _collectChangedForNationality($nationality, $days)
protected function _collectChangedForNationality($nationality, $days, $folderPath)
{
$filename = sprintf('/tmp/%s.txt', $nationality);
$folderPath = rtrim($folderPath, '/');
$filename = sprintf('%s/%s.txt', $folderPath, $nationality);
$file_input = fopen($filename, 'w');
$organisationIDsForNationality = $this->_fetchOrganisationsForNationality($nationality);
if (empty($organisationIDsForNationality)) {

View File

@ -122,6 +122,7 @@ class AppController extends Controller
$this->set('loggedUser', $this->ACL->getUser());
$this->set('roleAccess', $this->ACL->getRoleAccess(false, false));
}
Configure::write('loggedUser', $user);
} else if ($this->ParamHandler->isRest()) {
throw new MethodNotAllowedException(__('Invalid user credentials.'));
}

View File

@ -8,11 +8,10 @@ use Cake\ORM\TableRegistry;
use \Cake\Database\Expression\QueryExpression;
use Cake\Http\Exception\UnauthorizedException;
use Cake\Core\Configure;
use PhpParser\Node\Stmt\Echo_;
class AuditLogsController extends AppController
{
public $filterFields = ['model_id', 'model', 'request_action', 'user_id', 'model_title'];
public $filterFields = ['model_id', 'model', 'request_action', 'user_id', 'model_title', 'AuditLogs.created'];
public $quickFilterFields = ['model', 'request_action', 'model_title'];
public $containFields = ['Users'];

View File

@ -9,6 +9,7 @@ use Cake\Utility\Inflector;
use Cake\Utility\Text;
use Cake\View\ViewBuilder;
use Cake\ORM\TableRegistry;
use Cake\ORM\Query;
use Cake\Routing\Router;
use Cake\Http\Exception\MethodNotAllowedException;
use Cake\Http\Exception\NotFoundException;
@ -48,6 +49,8 @@ class CRUDComponent extends Component
$optionFilters = empty($options['filters']) ? [] : $options['filters'];
foreach ($optionFilters as $i => $filter) {
$optionFilters[] = "{$filter} !=";
$optionFilters[] = "{$filter} >=";
$optionFilters[] = "{$filter} <=";
}
$params = $this->Controller->ParamHandler->harvestParams($optionFilters);
$params = $this->fakeContextFilter($options, $params);
@ -112,6 +115,7 @@ class CRUDComponent extends Component
if ($this->metaFieldsSupported()) {
$query = $this->includeRequestedMetaFields($query);
}
$this->setRequestedEntryAmount();
$data = $this->Controller->paginate($query, $this->Controller->paginate ?? []);
if (isset($options['afterFind'])) {
$function = $options['afterFind'];
@ -195,16 +199,26 @@ class CRUDComponent extends Component
$metaTemplates = $this->getMetaTemplates()->toArray();
$this->Controller->set('metaFieldsEnabled', true);
$this->Controller->set('metaTemplates', $metaTemplates);
$typeHandlers = $this->Table->getBehavior('MetaFields')->getTypeHandlers();
$typeHandlersOperators = [];
foreach ($typeHandlers as $type => $handler) {
$typeHandlersOperators[$type] = $handler::OPERATORS;
}
$this->Controller->set('typeHandlersOperators', $typeHandlersOperators);
} else {
$this->Controller->set('metaFieldsEnabled', false);
}
$filters = !empty($this->Controller->filterFields) ? $this->Controller->filterFields : [];
$typeHandlers = $this->Table->getBehavior('MetaFields')->getTypeHandlers();
$typeHandlersOperators = [];
foreach ($typeHandlers as $type => $handler) {
$typeHandlersOperators[$type] = $handler::OPERATORS;
}
$this->Controller->set('typeHandlersOperators', $typeHandlersOperators);
$typeMap = $this->Table->getSchema()->typeMap();
$associatedtypeMap = !empty($this->Controller->filterFields) ? $this->_getAssociatedTypeMap() : [];
$typeMap = array_merge(
$this->Table->getSchema()->typeMap(),
$associatedtypeMap
);
$typeMap = array_filter($typeMap, function ($field) use ($filters) {
return in_array($field, $filters);
}, ARRAY_FILTER_USE_KEY);
$this->Controller->set('typeMap', $typeMap);
$this->Controller->set('filters', $filters);
$this->Controller->viewBuilder()->setLayout('ajax');
$this->Controller->render('/genericTemplates/filters');
@ -466,6 +480,7 @@ class CRUDComponent extends Component
}
$entity->setDirty('meta_fields', true);
$entity->_metafields_to_delete = $metaFieldsToDelete;
return ['entity' => $entity, 'metafields_to_delete' => $metaFieldsToDelete];
}
@ -506,7 +521,7 @@ class CRUDComponent extends Component
$params['contain'] = [$params['contain'], 'MetaFields'];
}
}
$query = $this->Table->find()->where(['id' => $id]);
$query = $this->Table->find()->where(["{$this->TableAlias}.id" => $id]);
if (!empty($params['contain'])) {
$query->contain($params['contain']);
}
@ -713,6 +728,15 @@ class CRUDComponent extends Component
]);
}
protected function setRequestedEntryAmount()
{
$user = $this->Controller->ACL->getUser();
$tableSettings = IndexSetting::getTableSetting($user, $this->Table);
if (!empty($tableSettings['number_of_element'])) {
$this->Controller->paginate['limit'] = intval($tableSettings['number_of_element']);
}
}
public function view(int $id, array $params = []): void
{
if (empty($id)) {
@ -1309,7 +1333,7 @@ class CRUDComponent extends Component
{
if (empty($params['filteringLabel']) && !empty($options['contextFilters']['custom'])) {
foreach ($options['contextFilters']['custom'] as $contextFilter) {
if (!empty($contextFilter['default'])) {
if (!empty($contextFilter['default']) && empty($params)) {
$params['filteringLabel'] = $contextFilter['label'];
$this->Controller->set('fakeFilteringLabel', $contextFilter['label']);
break;
@ -1434,7 +1458,7 @@ class CRUDComponent extends Component
);
if ($this->Controller->ParamHandler->isRest()) {
} else if ($this->Controller->ParamHandler->isAjax()) {
$this->Controller->ajaxResponsePayload = $this->RestResponse->ajaxFailResponse($this->ObjectAlias, 'toggle', $message, $validationErrors);
$this->Controller->ajaxResponsePayload = $this->RestResponse->ajaxFailResponse($this->ObjectAlias, 'toggle', [], $message, $validationErrors);
} else {
$this->Controller->Flash->error($message);
if (empty($params['redirect'])) {
@ -1534,4 +1558,24 @@ class CRUDComponent extends Component
$view = $builder->build($data);
return $view->render();
}
protected function _getAssociatedTypeMap(): array
{
$typeMap = [];
foreach ($this->Controller->filterFields as $filter) {
$exploded = explode('.', $filter);
if (count($exploded) > 1) {
$model = $exploded[0];
$subField = $exploded[1];
if ($model == $this->Table->getAlias()) {
$typeMap[$filter] = $this->Table->getSchema()->typeMap()[$subField] ?? 'text';
} else {
$association = $this->Table->associations()->get($model);
$associatedTable = $association->getTarget();
$typeMap[$filter] = $associatedTable->getSchema()->typeMap()[$subField] ?? 'text';
}
}
}
return $typeMap;
}
}

View File

@ -10,13 +10,13 @@ class InboxNavigation extends BaseNavigation
$this->bcf->addRoute('Inbox', 'index', $this->bcf->defaultCRUD('Inbox', 'index'));
$this->bcf->addRoute('Inbox', 'view', $this->bcf->defaultCRUD('Inbox', 'view'));
$this->bcf->addRoute('Inbox', 'discard', [
'label' => __('Discard request'),
'label' => __('Discard message'),
'icon' => 'trash',
'url' => '/inbox/discard/{{id}}',
'url_vars' => ['id' => 'id'],
]);
$this->bcf->addRoute('Inbox', 'process', [
'label' => __('Process request'),
'label' => __('Process message'),
'icon' => 'cogs',
'url' => '/inbox/process/{{id}}',
'url_vars' => ['id' => 'id'],

View File

@ -10,13 +10,13 @@ class OutboxNavigation extends BaseNavigation
$this->bcf->addRoute('Outbox', 'index', $this->bcf->defaultCRUD('Outbox', 'index'));
$this->bcf->addRoute('Outbox', 'view', $this->bcf->defaultCRUD('Outbox', 'view'));
$this->bcf->addRoute('Outbox', 'discard', [
'label' => __('Discard request'),
'label' => __('Discard message'),
'icon' => 'trash',
'url' => '/outbox/discard/{{id}}',
'url_vars' => ['id' => 'id'],
]);
$this->bcf->addRoute('Outbox', 'process', [
'label' => __('Process request'),
'label' => __('Process message'),
'icon' => 'cogs',
'url' => '/outbox/process/{{id}}',
'url_vars' => ['id' => 'id'],

View File

@ -16,8 +16,8 @@ use Cake\Http\Exception\ForbiddenException;
class InboxController extends AppController
{
public $filterFields = ['scope', 'action', 'title', 'origin', 'comment'];
public $quickFilterFields = ['scope', 'action', ['title' => true], ['comment' => true]];
public $filterFields = ['scope', 'action', 'Inbox.created', 'severity', 'title', 'origin', 'message', 'Users.id', 'Users.username',];
public $quickFilterFields = ['scope', 'action', ['title' => true], ['message' => true], 'origin'];
public $containFields = ['Users'];
public $paginate = [
@ -39,9 +39,102 @@ class InboxController extends AppController
'filters' => $this->filterFields,
'quickFilters' => $this->quickFilterFields,
'contextFilters' => [
'fields' => [
'scope',
]
'custom' => [
[
'default' => true,
'label' => __('My Notifications'),
'filterConditionFunction' => function ($query) {
return $query->where(function(QueryExpression $exp) {
return $exp->or(['user_id' => $this->ACL->getUser()['id']])
->isNull('user_id');
});
}
],
[
'label' => __('User Registration'),
'filterConditionFunction' => function ($query) {
return $query->where([
'scope' => 'User',
'action' => 'Registration',
]);
}
],
[
'label' => __('Inter-connection Requests'),
'filterConditionFunction' => function ($query) {
return $query->where([
'scope' => 'LocalTool',
'action IN' => ['IncomingConnectionRequest', 'AcceptedRequest', 'DeclinedRequest'],
]);
}
],
[
'label' => __('Data changed'),
'filterConditionFunction' => function ($query) {
return $query->where([
'user_id' => $this->ACL->getUser()['id'], // Each admin get a message about data changes
'scope' => 'Notification',
'action' => 'DataChange',
]);
}
],
[
'label' => 'severity:primary',
'viewElement' => 'bootstrapUI',
'viewElementParams' => [
'element' => 'badge',
'text' => $this->Inbox->severityVariant[$this->Inbox::SEVERITY_PRIMARY],
'variant' => $this->Inbox->severityVariant[$this->Inbox::SEVERITY_PRIMARY],
],
'filterConditionFunction' => function ($query) {
return $query->where([
'severity' => $this->Inbox::SEVERITY_PRIMARY,
]);
}
],
[
'label' => 'severity:info',
'viewElement' => 'bootstrapUI',
'viewElementParams' => [
'element' => 'badge',
'text' => $this->Inbox->severityVariant[$this->Inbox::SEVERITY_INFO],
'variant' => $this->Inbox->severityVariant[$this->Inbox::SEVERITY_INFO],
],
'filterConditionFunction' => function ($query) {
return $query->where([
'severity' => $this->Inbox::SEVERITY_INFO,
]);
}
],
[
'label' => 'severity:warning',
'viewElement' => 'bootstrapUI',
'viewElementParams' => [
'element' => 'badge',
'text' => $this->Inbox->severityVariant[$this->Inbox::SEVERITY_WARNING],
'variant' => $this->Inbox->severityVariant[$this->Inbox::SEVERITY_WARNING],
],
'filterConditionFunction' => function ($query) {
return $query->where([
'severity' => $this->Inbox::SEVERITY_WARNING,
]);
}
],
[
'label' => 'severity:danger',
'viewElement' => 'bootstrapUI',
'viewElementParams' => [
'element' => 'badge',
'text' => $this->Inbox->severityVariant[$this->Inbox::SEVERITY_DANGER],
'variant' => $this->Inbox->severityVariant[$this->Inbox::SEVERITY_DANGER],
],
'filterConditionFunction' => function ($query) {
return $query->where([
'severity' => $this->Inbox::SEVERITY_DANGER,
]);
}
],
],
],
'contain' => $this->containFields
]);
@ -99,11 +192,11 @@ class InboxController extends AppController
}
}
}
$this->set('deletionTitle', __('Discard request'));
$this->set('deletionTitle', __('Discard message'));
if (!empty($id)) {
$this->set('deletionText', __('Are you sure you want to discard request #{0}?', $id));
$this->set('deletionText', __('Are you sure you want to discard message #{0}?', $id));
} else {
$this->set('deletionText', __('Are you sure you want to discard the selected requests?'));
$this->set('deletionText', __('Are you sure you want to discard the selected message?'));
}
$this->set('deletionConfirm', __('Discard'));
$this->CRUD->delete($id);

View File

@ -20,46 +20,58 @@ class OrganisationsController extends AppController
public function index()
{
$customContextFilters = [
// [
// 'label' => __('ENISA Accredited'),
// 'filterCondition' => [
// 'MetaFields.field' => 'enisa-tistatus',
// 'MetaFields.value' => 'Accredited',
// 'MetaFields.MetaTemplates.name' => 'ENISA CSIRT Network'
// ]
// ],
// [
// 'label' => __('ENISA not-Accredited'),
// 'filterCondition' => [
// 'MetaFields.field' => 'enisa-tistatus',
// 'MetaFields.value !=' => 'Accredited',
// 'MetaFields.MetaTemplates.name' => 'ENISA CSIRT Network'
// ]
// ],
// [
// 'label' => __('ENISA CSIRT Network (GOV)'),
// 'filterConditionFunction' => function ($query) {
// return $this->CRUD->setParentConditionsForMetaFields($query, [
// 'ENISA CSIRT Network' => [
// [
// 'field' => 'constituency',
// 'value LIKE' => '%Government%',
// ],
// [
// 'field' => 'csirt-network-status',
// 'value' => 'Member',
// ],
// ]
// ]);
// }
// ],
];
$loggedUserOrganisationNationality = $this->ACL->getUser()['organisation']['nationality'];
if (!empty($loggedUserOrganisationNationality)) {
$customContextFilters[] = [
'label' => __('Country: {0}', $loggedUserOrganisationNationality),
'filterCondition' => [
'nationality' => $loggedUserOrganisationNationality,
]
];
}
$this->CRUD->index([
'filters' => $this->filterFields,
'quickFilters' => $this->quickFilterFields,
'quickFilterForMetaField' => ['enabled' => true, 'wildcard_search' => true],
'contextFilters' => [
'custom' => [
[
'label' => __('ENISA Accredited'),
'filterCondition' => [
'MetaFields.field' => 'enisa-tistatus',
'MetaFields.value' => 'Accredited',
'MetaFields.MetaTemplates.name' => 'ENISA CSIRT Network'
]
],
[
'label' => __('ENISA not-Accredited'),
'filterCondition' => [
'MetaFields.field' => 'enisa-tistatus',
'MetaFields.value !=' => 'Accredited',
'MetaFields.MetaTemplates.name' => 'ENISA CSIRT Network'
]
],
[
'label' => __('ENISA CSIRT Network (GOV)'),
'filterConditionFunction' => function($query) {
return $this->CRUD->setParentConditionsForMetaFields($query, [
'ENISA CSIRT Network' => [
[
'field' => 'constituency',
'value LIKE' => '%Government%',
],
[
'field' => 'csirt-network-status',
'value' => 'Member',
],
]
]);
}
]
],
'custom' => $customContextFilters,
],
'contain' => $this->containFields,
'statisticsFields' => $this->statisticsFields,

View File

@ -16,8 +16,8 @@ use Cake\Http\Exception\ForbiddenException;
class OutboxController extends AppController
{
public $filterFields = ['scope', 'action', 'title', 'comment'];
public $quickFilterFields = ['scope', 'action', ['title' => true], ['comment' => true]];
public $filterFields = ['scope', 'action', 'title', 'message'];
public $quickFilterFields = ['scope', 'action', ['title' => true], ['message' => true]];
public $containFields = ['Users'];
public function beforeFilter(EventInterface $event)

View File

@ -21,11 +21,23 @@ class UsersController extends AppController
if (empty($currentUser['role']['perm_admin'])) {
$conditions['organisation_id'] = $currentUser['organisation_id'];
}
$keycloakUsersParsed = null;
if (!empty(Configure::read('keycloak.enabled'))) {
// $keycloakUsersParsed = $this->Users->getParsedKeycloakUser();
}
$this->CRUD->index([
'contain' => $this->containFields,
'filters' => $this->filterFields,
'quickFilters' => $this->quickFilterFields,
'conditions' => $conditions
'conditions' => $conditions,
'afterFind' => function($data) use ($keycloakUsersParsed) {
// TODO: We might want to uncomment this at some point Still need to evaluate the impact
// if (!empty(Configure::read('keycloak.enabled'))) {
// $keycloakUser = $keycloakUsersParsed[$data->username];
// $data['keycloak_status'] = array_values($this->Users->checkKeycloakStatus([$data->toArray()], [$keycloakUser]))[0];
// }
return $data;
}
]);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
@ -56,7 +68,7 @@ class UsersController extends AppController
} else {
$validRoles = $this->Users->Roles->find('list')->order(['name' => 'asc'])->all()->toArray();
}
$defaultRole = $this->Users->Roles->find()->select(['id'])->first()->toArray();
$defaultRole = $this->Users->Roles->find()->select(['id'])->where(['is_default' => true])->first()->toArray();
$individuals = $this->Users->Individuals->find('list', $individuals_params)->toArray();
$this->CRUD->add([
'beforeMarshal' => function($data) {
@ -126,7 +138,7 @@ class UsersController extends AppController
'organisation' => $this->Users->Organisations->find('list', [
'sort' => ['name' => 'asc'],
'conditions' => $org_conditions
])
])->toArray()
];
$this->set(compact('dropdownData'));
$this->set('defaultRole', $defaultRole['id'] ?? null);
@ -139,10 +151,18 @@ class UsersController extends AppController
if (empty($id) || (empty($currentUser['role']['perm_org_admin']) && empty($currentUser['role']['perm_admin']))) {
$id = $this->ACL->getUser()['id'];
}
$keycloakUsersParsed = null;
if (!empty(Configure::read('keycloak.enabled'))) {
$keycloakUsersParsed = $this->Users->getParsedKeycloakUser();
}
$this->CRUD->view($id, [
'contain' => ['Individuals' => ['Alignments' => 'Organisations'], 'Roles', 'Organisations'],
'afterFind' => function($data) {
'afterFind' => function($data) use ($keycloakUsersParsed) {
$data = $this->fetchTable('PermissionLimitations')->attachLimitations($data);
if (!empty(Configure::read('keycloak.enabled'))) {
$keycloakUser = $keycloakUsersParsed[$data->username];
$data['keycloak_status'] = array_values($this->Users->checkKeycloakStatus([$data->toArray()], [$keycloakUser]))[0];
}
return $data;
}
]);
@ -150,7 +170,7 @@ class UsersController extends AppController
if (!empty($responsePayload)) {
return $responsePayload;
}
$this->set('keycloakConfig', Configure::read('keycloak'));
$this->set('keycloakConfig', Configure::read('keycloak', ['enabled' => false]));
$this->set('metaGroup', $this->isAdmin ? 'Administration' : 'Cerebrate');
}
@ -176,11 +196,6 @@ class UsersController extends AppController
$id = $currentUser['id'];
} else {
$id = intval($id);
if ((empty($currentUser['role']['perm_org_admin']) && empty($currentUser['role']['perm_admin']))) {
if ($id !== $currentUser['id']) {
throw new MethodNotAllowedException(__('You are not authorised to edit that user.'));
}
}
}
$params = [
@ -189,13 +204,24 @@ class UsersController extends AppController
],
'fields' => [
'password', 'confirm_password'
]
],
'contain' => ['Roles', ],
];
if ($this->request->is(['get'])) {
$params['fields'] = array_merge($params['fields'], ['individual_id', 'role_id', 'disabled']);
if (!empty($this->ACL->getUser()['role']['perm_admin'])) {
$params['fields'][] = 'organisation_id';
}
if (!$currentUser['role']['perm_admin']) {
$params['afterFind'] = function ($user, &$params) use ($currentUser) {
if (!empty($user)) { // We don't have a 404
if (!$this->ACL->canEditUser($currentUser, $user)) {
throw new MethodNotAllowedException(__('You cannot edit the given user.'));
}
}
return $user;
};
}
}
if ($this->request->is(['post', 'put']) && !empty($this->ACL->getUser()['role']['perm_admin'])) {
$params['fields'][] = 'individual_id';
@ -210,7 +236,7 @@ class UsersController extends AppController
if (!in_array($data['role_id'], array_keys($validRoles))) {
throw new MethodNotAllowedException(__('You cannot edit the given privileged user.'));
}
if ($data['organisation_id'] !== $currentUser['organisation_id']) {
if (!$this->ACL->canEditUser($currentUser, $data)) {
throw new MethodNotAllowedException(__('You cannot edit the given user.'));
}
return $data;
@ -247,7 +273,7 @@ class UsersController extends AppController
'organisation' => $this->Users->Organisations->find('list', [
'sort' => ['name' => 'asc'],
'conditions' => $org_conditions
])
])->toArray()
];
$this->set(compact('dropdownData'));
$this->set('metaGroup', $this->isAdmin ? 'Administration' : 'Cerebrate');
@ -416,6 +442,8 @@ class UsersController extends AppController
'first_name' => $data['first_name'],
'last_name' => $data['last_name'],
'password' => $data['password'],
'org_name' => $data['org_name'],
'org_uuid' => $data['org_uuid'],
],
];
$processorResult = $processor->create($data);

View File

@ -52,7 +52,7 @@ class AuthKeycloakBehavior extends Behavior
->where(['username' => $profile_payload[$fields['username']]])
->contain('Individuals')
->first();
if ($existingUser['individual']['email'] !== $profile_payload[$fields['email']]) {
if (mb_strtolower($existingUser['individual']['email']) !== mb_strtolower($profile_payload[$fields['email']])) {
return false;
}
return $existingUser;
@ -284,27 +284,45 @@ class AuthKeycloakBehavior extends Behavior
{
$this->updateMappers();
$results = [];
$data['Users'] = $this->_table->find()->contain(['Individuals', 'Organisations', 'Roles'])->select(
[
'id',
'uuid',
'username',
'disabled',
'Individuals.email',
'Individuals.first_name',
'Individuals.last_name',
'Individuals.uuid',
'Roles.name',
'Roles.uuid',
'Organisations.name',
'Organisations.uuid'
]
)->disableHydration()->toArray();
$data['Users'] = $this->getCerebrateUsers();
$clientId = $this->getClientId();
return $this->syncUsers($data['Users'], $clientId);
}
private function syncUsers(array $users, $clientId): array
{
$keycloakUsersParsed = $this->getParsedKeycloakUser();
$changes = [
'created' => [],
'modified' => [],
];
foreach ($users as &$user) {
try {
if (empty($keycloakUsersParsed[$user['username']])) {
if ($this->createUser($user, $clientId)) {
$changes['created'][] = $user['username'];
}
} else {
if ($this->checkAndUpdateUser($keycloakUsersParsed[$user['username']], $user)) {
$changes['modified'][] = $user['username'];
}
}
} catch (\Exception $e) {
$this->_table->auditLogs()->insert([
'request_action' => 'syncUsers',
'model' => 'User',
'model_id' => 0,
'model_title' => __('Failed to create or modify user ({0}) in keycloak', $user['username']),
'changed' => [
'message' => $e->getMessage(),
]
]);
}
}
return $changes;
}
public function getParsedKeycloakUser(): array
{
$response = $this->restApiRequest('%s/admin/realms/%s/users', [], 'get');
$keycloakUsers = json_decode($response->getStringBody(), true);
@ -325,37 +343,30 @@ class AuthKeycloakBehavior extends Behavior
]
];
}
$changes = [
'created' => [],
'modified' => [],
];
foreach ($users as &$user) {
$changed = false;
if (empty($keycloakUsersParsed[$user['username']])) {
if ($this->createUser($user, $clientId)) {
$changes['created'][] = $user['username'];
}
} else {
if ($this->checkAndUpdateUser($keycloakUsersParsed[$user['username']], $user)) {
$changes['modified'][] = $user['username'];
}
}
}
return $changes;
return $keycloakUsersParsed;
}
private function getCerebrateUsers(): array
{
return $this->_table->find()->contain(['Individuals', 'Organisations', 'Roles'])->select([
'id',
'uuid',
'username',
'disabled',
'Individuals.email',
'Individuals.first_name',
'Individuals.last_name',
'Individuals.uuid',
'Roles.name',
'Roles.uuid',
'Organisations.name',
'Organisations.uuid'
])->disableHydration()->toArray();
}
private function checkAndUpdateUser(array $keycloakUser, array $user): bool
{
if (
$keycloakUser['enabled'] == $user['disabled'] ||
$keycloakUser['firstName'] !== $user['individual']['first_name'] ||
$keycloakUser['lastName'] !== $user['individual']['last_name'] ||
$keycloakUser['email'] !== $user['individual']['email'] ||
(empty($keycloakUser['attributes']['role_name']) || $keycloakUser['attributes']['role_name'] !== $user['role']['name']) ||
(empty($keycloakUser['attributes']['role_uuid']) || $keycloakUser['attributes']['role_uuid'] !== $user['role']['uuid']) ||
(empty($keycloakUser['attributes']['org_name']) || $keycloakUser['attributes']['org_name'] !== $user['organisation']['name']) ||
(empty($keycloakUser['attributes']['org_uuid']) || $keycloakUser['attributes']['org_uuid'] !== $user['organisation']['uuid'])
) {
if ($this->checkKeycloakUserRequiresUpdate($keycloakUser, $user)) {
$change = [
'enabled' => !$user['disabled'],
'firstName' => $user['individual']['first_name'],
@ -387,6 +398,63 @@ class AuthKeycloakBehavior extends Behavior
return false;
}
public function checkKeycloakStatus(array $users, array $keycloakUsers): array
{
$users = Hash::combine($users, '{n}.username', '{n}');
$keycloakUsersParsed = Hash::combine($keycloakUsers, '{n}.username', '{n}');
$status = [];
foreach ($users as $username => $user) {
$differences = [];
$requireUpdate = $this->checkKeycloakUserRequiresUpdate($keycloakUsersParsed[$username], $user, $differences);
$status[$user['id']] = [
'require_update' => $requireUpdate,
'differences' => $differences,
];
}
return $status;
}
private function checkKeycloakUserRequiresUpdate(array $keycloakUser, array $user, array &$differences = []): bool
{
$condEnabled = $keycloakUser['enabled'] == $user['disabled'];
$condFirstname = mb_strtolower($keycloakUser['firstName']) !== mb_strtolower($user['individual']['first_name']);
$condLastname = mb_strtolower($keycloakUser['lastName']) !== mb_strtolower($user['individual']['last_name']);
$condEmail = mb_strtolower($keycloakUser['email']) !== mb_strtolower($user['individual']['email']);
$condRolename = (empty($keycloakUser['attributes']['role_name']) || mb_strtolower($keycloakUser['attributes']['role_name']) !== mb_strtolower($user['role']['name']));
$condRoleuuid = (empty($keycloakUser['attributes']['role_uuid']) || mb_strtolower($keycloakUser['attributes']['role_uuid']) !== mb_strtolower($user['role']['uuid']));
$condOrgname = (empty($keycloakUser['attributes']['org_name']) || mb_strtolower($keycloakUser['attributes']['org_name']) !== mb_strtolower($user['organisation']['name']));
$condOrguuid = (empty($keycloakUser['attributes']['org_uuid']) || mb_strtolower($keycloakUser['attributes']['org_uuid']) !== mb_strtolower($user['organisation']['uuid']));
if ($condEnabled || $condFirstname || $condLastname || $condEmail || $condRolename || $condRoleuuid || $condOrgname || $condOrguuid) {
if ($condEnabled) {
$differences['enabled'] = ['keycloak' => $keycloakUser['enabled'], 'cerebrate' => $user['disabled']];
}
if ($condFirstname) {
$differences['first_name'] = ['keycloak' => $keycloakUser['firstName'], 'cerebrate' => $user['individual']['first_name']];
}
if ($condLastname) {
$differences['last_name'] = ['keycloak' => $keycloakUser['lastName'], 'cerebrate' => $user['individual']['last_name']];
}
if ($condEmail) {
$differences['email'] = ['keycloak' => $keycloakUser['email'], 'cerebrate' => $user['individual']['email']];
}
if ($condRolename) {
$differences['role_name'] = ['keycloak' => $keycloakUser['attributes']['role_name'], 'cerebrate' => $user['role']['name']];
}
if ($condRoleuuid) {
$differences['role_uuid'] = ['keycloak' => $keycloakUser['attributes']['role_uuid'], 'cerebrate' => $user['role']['uuid']];
}
if ($condOrgname) {
$differences['org_name'] = ['keycloak' => $keycloakUser['attributes']['org_name'], 'cerebrate' => $user['organisation']['name']];
}
if ($condOrguuid) {
$differences['org_uuid'] = ['keycloak' => $keycloakUser['attributes']['org_uuid'], 'cerebrate' => $user['organisation']['uuid']];
}
return true;
}
return false;
}
private function createUser(array $user, string $clientId)
{
$newUser = [

View File

@ -0,0 +1,332 @@
<?php
namespace App\Model\Behavior;
use ArrayObject;
use App\Model\Entity\AppModel;
use App\Model\Entity\MetaField;
use Cake\Core\Configure;
use App\Model\Table\UsersTable;
use Cake\ORM\Behavior;
use Cake\Datasource\EntityInterface;
use Cake\Event\EventInterface;
use Cake\ORM\TableRegistry;
use Cake\ORM\Query;
use Cake\Utility\Inflector;
use Cake\Utility\Hash;
use Cake\Routing\Router;
class NotifyAdminsBehavior extends Behavior
{
/** @var array */
protected $_defaultConfig = [
'implementedEvents' => [
'Model.afterSave' => 'afterSave',
'Model.afterDelete' => 'afterDelete',
'Model.beforeDelete' => 'beforeDelete',
],
'implementedMethods' => [
'notifySiteAdmins' => 'notifySiteAdmins',
'notifySiteAdminsForEntity' => 'notifySiteAdminsForEntity',
],
];
/** @var AuditLog|null */
private $Inbox;
/** @var User|null */
private $Users;
/** @var InboxProcessors|null */
private $InboxProcessors;
public function initialize(array $config): void
{
$this->Inbox = TableRegistry::getTableLocator()->get('Inbox');
if($this->table() instanceof UsersTable) {
$this->Users = $this->table();
} else {
$this->Users = TableRegistry::getTableLocator()->get('Users');
}
}
public function afterSave(EventInterface $event, EntityInterface $entity, ArrayObject $options): void
{
if (!$this->isNotificationAllowed($event, $entity, $options)) {
return;
}
$this->notifySiteAdminsForEntityChange($entity);
}
public function beforeDelete(EventInterface $event, EntityInterface $entity, ArrayObject $options): void
{
if ($entity->table()->hasBehavior('MetaFields') && !isset($entity->meta_fields)) {
$entity = $entity->table()->loadInto($entity, ['MetaFields']);
}
}
public function afterDelete(EventInterface $event, EntityInterface $entity, ArrayObject $options): void
{
if (!$this->isNotificationAllowed($event, $entity, $options)) {
return;
}
$this->notifySiteAdminsForEntityDeletion($entity);
}
public function isNotificationAllowed(EventInterface $event, EntityInterface $entity, ArrayObject $options): bool
{
$loggedUser = Configure::read('loggedUser');
if (empty($loggedUser) || !empty($loggedUser['role']['perm_admin']) || !empty($loggedUser['role']['perm_sync'])) {
return false;
}
return true;
}
public function notifySiteAdminsForEntityChange(EntityInterface $entity): void
{
$watchedFields = !empty($this->getConfig()['fields']) ? $this->getConfig()['fields'] : $entity->getVisible();
$originalFields = [];
$changedFields = $entity->extract($watchedFields);
$titleTemplate = 'New {0} `{1}` created';
$title = __(
$titleTemplate,
Inflector::singularize($entity->getSource()),
$entity->get($this->table()->getDisplayField())
);
$message = __('New {0}', Inflector::singularize($entity->getSource()));
if (!$entity->isNew()) {
$originalFields = $this->_getOriginalFields($entity, $watchedFields);
$changedFields = $this->_getChangedFields($entity, $originalFields);
if (
$entity->table()->hasBehavior('Timestamp') &&
count($changedFields) == 1 && !empty($changedFields['modified'])
) {
return; // A not watched field has changed
}
$changeAmount = $this->_computeChangeAmount($entity, $originalFields, $changedFields);
$titleTemplate = '{0} {1} modified';
$title = __(
$titleTemplate,
Inflector::singularize($entity->getSource()),
$entity->get($this->table()->getDisplayField())
);
$message = __n(
'{0} field was updated',
'{0} fields were updated',
$changeAmount,
$changeAmount
);
}
if ($entity->table()->hasBehavior('MetaFields')) {
$originalFields['meta_fields'] = $this->_massageMetaField($entity, $originalFields['meta_fields'] ?? []);
$changedFields['meta_fields'] = $this->_massageMetaField($entity, $changedFields['meta_fields'] ?? []);
if (empty($originalFields['meta_fields']) && empty($changedFields['meta_fields'])) {
unset($originalFields['meta_fields']);
unset($changedFields['meta_fields']);
}
}
$originalFields = $entity->isNew() ? [] : $originalFields;
$data = [
'original' => $this->_serializeFields($originalFields),
'changed' => $this->_serializeFields($changedFields),
'summaryTemplate' => $titleTemplate,
'summaryMessage' => $message,
'entityType' => Inflector::singularize($entity->getSource()),
'entityDisplayField' => $entity->get($this->table()->getDisplayField()),
'entityViewURL' => Router::url([
'controller' => Inflector::underscore($entity->getSource()),
'action' => 'view',
$entity->id
]),
];
$this->notifySiteAdmins($entity->getSource(), $title, $message, $data);
}
public function notifySiteAdminsForEntityDeletion(EntityInterface $entity): void
{
$watchedFields = !empty($this->getConfig()['fields']) ? $this->getConfig()['fields'] : $entity->getVisible();
$originalFields = $entity->extract($watchedFields);
$changedFields = [];
$titleTemplate = 'Deleted {0} `{1}`';
$title = __(
$titleTemplate,
Inflector::singularize($entity->getSource()),
$entity->get($this->table()->getDisplayField())
);
$message = __('{0} deleted', Inflector::singularize($entity->getSource()));
$data = [
'original' => $this->_serializeFields($originalFields),
'changed' => $this->_serializeFields($changedFields),
'summaryTemplate' => $titleTemplate,
'summaryMessage' => $message,
'entityType' => Inflector::singularize($entity->getSource()),
'entityDisplayField' => $entity->get($this->table()->getDisplayField()),
'entityViewURL' => Router::url([
'controller' => Inflector::underscore($entity->getSource()),
'action' => 'view',
$entity->id
]),
];
$this->notifySiteAdmins($entity->getSource(), $title, $message, $data);
}
/**
* Create a message in each site-admin users
*
* @param string $title A quick summary of what that notification is about
* @param string $origin The origin of the notification. For example, could be the model source
* @param string $message Even more free text
* @param array $data data used to generate the view when processing.
* Must contain: `original` array of the data before the change
* Must contain: `changed` array of the data after the change
* Optional: `summary` A text summarizing the change
* Optional: `summaryTemplate`, `summaryMessage`, `entityType`, `entityDisplayField`, `entityViewURL` text used to build a summary of the change
* @return void
*/
public function notifySiteAdmins(
string $origin,
string $title,
string $message = '',
array $data
): void {
$this->InboxProcessors = $this->InboxProcessors ?: TableRegistry::getTableLocator()->get('InboxProcessors');
$processor = $this->InboxProcessors->getProcessor('Notification', 'DataChange');
$siteAdmins = $this->_getSiteAdmins();
foreach ($siteAdmins as $siteAdmin) {
$notificationData = [
'origin' => $origin,
'title' => $title,
'user_id' => $siteAdmin->id,
'message' => $message,
'data' => $data,
];
$processor->create($notificationData);
}
}
protected function _getSiteAdmins(): array
{
return $this->Users->find()
->matching('Roles', function(Query $q) {
return $q
->where(['Roles.perm_admin' => true]);
})
->all()->toList();
}
protected function _getOriginalFields(AppModel $entity, array $fields): array
{
$originalChangedFields = $entity->extractOriginalChanged($fields);
$originalChangedFields = array_map(function ($fieldValue) {
if (is_array($fieldValue)) {
return array_filter(array_map(function($fieldValue) {
if (is_subclass_of($fieldValue, 'App\Model\Entity\AppModel')) {
if (!empty($fieldValue->extractOriginalChanged($fieldValue->getVisible()))) {
return $fieldValue;
} else {
return null;
}
}
return $fieldValue;
}, $fieldValue), fn ($v) => !is_null($v));
} else if (is_subclass_of($fieldValue, 'App\Model\Entity\AppModel')) {
return $fieldValue->extractOriginalChanged($fieldValue);
}
return $fieldValue;
}, $originalChangedFields);
if ($entity->table()->hasBehavior('MetaFields')) {
// Include deleted meta-fields
$originalChangedFields['meta_fields'] = array_merge(
$originalChangedFields['meta_fields'] ?? [],
$this->_getDeletedMetafields($entity)
);
// Restore original values of meta-fields as the entity has been saved with the changes
if (!empty($entity->meta_fields)) {
$originalChangedFields['meta_fields'] = array_map(function ($metaField) {
$originalValues = $metaField->getOriginalValues($metaField->getVisible());
$originalMetafield = $metaField->table()->newEntity($metaField->toArray());
$originalMetafield->set($originalValues);
return $originalMetafield;
}, $originalChangedFields['meta_fields']);
}
}
return $originalChangedFields;
}
protected function _getChangedFields(AppModel $entity, array $originalFields): array
{
$changedFields = array_filter(
$entity->extract(array_keys($originalFields)),
fn ($v) => !is_null($v)
);
if ($entity->table()->hasBehavior('MetaFields')) {
$changedMetafields = $entity->extractOriginalChanged($entity->getVisible())['meta_fields'] ?? [];
$changedFields['meta_fields'] = array_filter($changedMetafields, function($metaField) {
return !empty($metaField->getDirty());
});
}
return $changedFields;
}
protected function _massageMetaField(AppModel $entity, array $metaFields): array
{
$massaged = [];
foreach ($metaFields as $metaField) {
foreach ($entity->MetaTemplates as $template) {
$templateDisplayName = sprintf('%s (v%s)', $template->name, $template->version);
foreach ($template['meta_template_fields'] as $field) {
if ($metaField->meta_template_id == $template->id && $metaField->meta_template_field_id == $field->id) {
if (!empty($massaged[$templateDisplayName][$field['field']])) {
if (!is_array($massaged[$templateDisplayName][$field['field']])) {
$massaged[$templateDisplayName][$field['field']] = [$massaged[$templateDisplayName][$field['field']]];
}
$massaged[$templateDisplayName][$field['field']][] = $metaField['value'];
} else {
$massaged[$templateDisplayName][$field['field']] = $metaField['value'];
}
break 2;
}
}
}
}
return $massaged;
}
protected function _getDeletedMetafields(AppModel $entity): array
{
return $entity->_metafields_to_delete ?? [];
}
protected function _computeChangeAmount(AppModel $entity, array $originalFields, array $changedFields): int
{
$amount = count($changedFields);
if ($entity->table()->hasBehavior('MetaFields')) {
$amount -= 1; // `meta_fields` key was counted without checking at the content
}
if (!empty($originalFields['meta_fields']) && !empty($changedFields['meta_fields'])) {
$amount += count(array_intersect_key($originalFields['meta_fields'] ?? [], $changedFields['meta_fields'] ?? [])); // Add changed fields
$amount += count(array_diff_key($changedFields['meta_fields'] ?? [], $originalFields['meta_fields'] ?? [])); // Add new fields
$amount += count(array_diff_key($originalFields['meta_fields'] ?? [], $changedFields['meta_fields'] ?? [])); // Add deleted fields
}
return $amount;
}
protected function _serializeFields($fields): array
{
return array_map(function ($fieldValue) {
if (is_bool($fieldValue)) {
return empty($fieldValue) ? 0 : 1;
} else if (is_array($fieldValue)) {
return json_encode($fieldValue);
} else if (is_object($fieldValue)) {
switch (get_class($fieldValue)) {
case 'Cake\I18n\FrozenTime':
return $fieldValue->i18nFormat('yyyy-mm-dd HH:mm:ss');
}
} else {
return strval($fieldValue);
}
}, $fields);
}
}

View File

@ -2,7 +2,9 @@
namespace App\Model\Entity;
use App\Model\Table\AppTable;
use Cake\ORM\Entity;
use Cake\ORM\TableRegistry;
class AppModel extends Entity
{
@ -33,6 +35,11 @@ class AppModel extends Entity
return $this->_accessibleOnNew ?? [];
}
public function table(): AppTable
{
return TableRegistry::get($this->getSource());
}
public function rearrangeForAPI(): void
{
}

View File

@ -7,7 +7,7 @@ use Cake\ORM\Entity;
class Inbox extends AppModel
{
protected $_virtual = ['local_tool_connector_name'];
protected $_virtual = ['local_tool_connector_name', 'severity_variant'];
protected function _getLocalToolConnectorName()
{
@ -17,4 +17,9 @@ class Inbox extends AppModel
}
return $localConnectorName;
}
protected function _getSeverityVariant(): string
{
return $this->table()->severityVariant[$this->severity];
}
}

View File

@ -7,5 +7,45 @@ use Cake\ORM\Entity;
class MetaTemplateField extends AppModel
{
protected $_virtual = ['index_type', 'form_type', 'form_options', ];
protected function _getIndexType()
{
$indexType = 'text';
if ($this->type === 'boolean') {
$indexType = 'boolean';
} else if ($this->type === 'date') {
$indexType = 'datetime';
} else if ($this->type === 'ipv4' || $this->type === 'ipv6') {
$indexType = 'text';
}
return $indexType;
}
protected function _getFormType()
{
$formType = 'text';
if (!empty($this->sane_default) || !empty($this->values_list)) {
$formType = 'dropdown';
} else if ($this->type === 'boolean') {
$formType = 'checkbox';
}
return $formType;
}
protected function _getFormOptions()
{
$formOptions = [];
if ($this->formType === 'dropdown') {
$selectOptions = !empty($this->sane_default) ? $this->sane_default : $this->values_list;
$selectOptions = array_combine($selectOptions, $selectOptions);
if (!empty($this->sane_default)) {
$selectOptions[] = ['value' => '_custom', 'text' => __('-- custom value --'), 'class' => 'custom-value'];
}
$selectOptions[''] = __('-- no value --');
$formOptions['options'] = $selectOptions;
}
return $formOptions;
}
}

View File

@ -7,5 +7,10 @@ use Cake\ORM\Entity;
class Outbox extends AppModel
{
protected $_virtual = ['severity_variant'];
protected function _getSeverityVariant(): string
{
return $this->table()->severityVariant[$this->severity];
}
}

View File

@ -16,6 +16,7 @@ class AppTable extends Table
{
public function initialize(array $config): void
{
FrozenTime::setToStringFormat("yyyy-MM-dd HH:mm:ss");
}
public function getStatisticsUsageForModel(Object $table, array $scopes, array $options=[]): array

View File

@ -8,6 +8,7 @@ use Cake\ORM\Table;
use Cake\ORM\RulesChecker;
use Cake\Validation\Validator;
use Cake\Http\Exception\NotFoundException;
use Cake\ORM\ResultSet;
use App\Utility\UI\Notification;
@ -15,6 +16,17 @@ Type::map('json', 'Cake\Database\Type\JsonType');
class InboxTable extends AppTable
{
public const SEVERITY_PRIMARY = 0,
SEVERITY_INFO = 1,
SEVERITY_WARNING = 2,
SEVERITY_DANGER = 3;
public $severityVariant = [
self::SEVERITY_PRIMARY => 'primary',
self::SEVERITY_INFO => 'info',
self::SEVERITY_WARNING => 'warning',
self::SEVERITY_DANGER => 'danger',
];
public function initialize(array $config): void
{
@ -97,8 +109,8 @@ class InboxTable extends AppTable
$allNotifications = [];
$inboxNotifications = $this->getNotificationsForUser($user);
foreach ($inboxNotifications as $notification) {
$title = __('New message');
$details = $notification->title;
$title = $notification->title;
$details = $notification->message;
$router = [
'controller' => 'inbox',
'action' => 'process',
@ -109,7 +121,7 @@ class InboxTable extends AppTable
'icon' => 'envelope',
'details' => $details,
'datetime' => $notification->created,
'variant' => 'warning',
'variant' => $notification->severity_variant,
'_useModal' => true,
'_sidebarId' => 'inbox',
]))->get();
@ -117,18 +129,14 @@ class InboxTable extends AppTable
return $allNotifications;
}
public function getNotificationsForUser(\App\Model\Entity\User $user): array
public function getNotificationsForUser(\App\Model\Entity\User $user): ResultSet
{
$query = $this->find();
$conditions = [];
if ($user['role']['perm_admin']) {
// Admin will not see notifications if it doesn't belong to them. They can see process the message from the inbox
$conditions['Inbox.user_id IS'] = null;
} else {
$conditions['Inbox.user_id'] = $user->id;
}
$conditions = [
'Inbox.user_id' => $user->id
];
$query->where($conditions);
$notifications = $query->all()->toArray();
$notifications = $query->all();
return $notifications;
}
}

View File

@ -27,6 +27,8 @@ class MetaTemplateFieldsTable extends AppTable
$this->hasMany('MetaFields');
$this->setDisplayField('field');
$this->getSchema()->setColumnType('sane_default', 'json');
$this->getSchema()->setColumnType('values_list', 'json');
$this->loadTypeHandlers();
}

View File

@ -16,6 +16,9 @@ class OrganisationsTable extends AppTable
$this->addBehavior('Timestamp');
$this->addBehavior('Tags.Tag');
$this->addBehavior('AuditLog');
$this->addBehavior('NotifyAdmins', [
'fields' => ['uuid', 'name', 'url', 'nationality', 'sector', 'type', 'contacts', 'modified', 'meta_fields'],
]);
$this->hasMany(
'Alignments',
[

View File

@ -8,11 +8,13 @@ use Cake\ORM\Table;
use Cake\ORM\RulesChecker;
use Cake\Validation\Validator;
use Cake\Http\Exception\NotFoundException;
use Cake\ORM\TableRegistry;
Type::map('json', 'Cake\Database\Type\JsonType');
class OutboxTable extends AppTable
{
public $severityVariant;
public function initialize(array $config): void
{
@ -22,6 +24,9 @@ class OutboxTable extends AppTable
$this->belongsTo('Users');
$this->addBehavior('AuditLog');
$this->setDisplayField('title');
$this->Inbox = TableRegistry::getTableLocator()->get('Inbox');
$this->severityVariant = $this->Inbox->severityVariant;
}
protected function _initializeSchema(TableSchemaInterface $schema): TableSchemaInterface

View File

@ -22,6 +22,7 @@ class PermissionLimitationsTable extends AppTable
$validator
->notEmptyString('permission')
->notEmptyString('scope')
->naturalNumber('max_occurrence', __('That field can only hold non-negative values.'))
->requirePresence(['permission', 'scope', 'max_occurrence'], 'create');
return $validator;
}
@ -44,6 +45,14 @@ class PermissionLimitationsTable extends AppTable
'limit' => $entry['max_occurrence']
];
}
foreach ($limitations as $i => $permissions) { // Make sure global and organisations permission are mirror in the case where one of the two is not defined
if (!isset($permissions['global']['limit'])) {
$limitations[$i]['global']['limit'] = $permissions['organisation']['limit'];
}
if (!isset($permissions['organisation']['limit'])) {
$limitations[$i]['organisation']['limit'] = $permissions['global']['limit'];
}
}
foreach ($limitations as $field => $data) {
if (isset($data['global'])) {
$limitations[$field]['global']['current'] = $MetaFields->find('all', [

View File

@ -25,6 +25,9 @@ class UsersTable extends AppTable
$this->addBehavior('UUID');
$this->addBehavior('MetaFields');
$this->addBehavior('AuditLog');
$this->addBehavior('NotifyAdmins', [
'fields' => ['role_id', 'individual_id', 'organisation_id', 'disabled', 'modified', 'meta_fields'],
]);
$this->initAuthBehaviors();
$this->belongsTo(
'Individuals',
@ -66,6 +69,7 @@ class UsersTable extends AppTable
public function beforeSave(EventInterface $event, EntityInterface $entity, ArrayObject $options)
{
$success = true;
if (!$entity->isNew()) {
$success = $this->handleUserUpdateRouter($entity);
}
@ -84,22 +88,33 @@ class UsersTable extends AppTable
if (!isset($this->PermissionLimitations)) {
$this->PermissionLimitations = TableRegistry::get('PermissionLimitations');
}
$new = $entity->isNew();
$permissions = $this->PermissionLimitations->getListOfLimitations($entity);
foreach ($permissions as $permission_name => $permission) {
foreach ($permission as $scope => $permission_data) {
if (!empty($entity['meta_fields'])) {
$enabled = false;
$valueToCompareTo = $permission_data['current'];
$enabled = false;
if (!empty($entity->meta_fields)) {
foreach ($entity['meta_fields'] as $metaField) {
if ($metaField['field'] === $permission_name) {
$enabled = true;
if ($metaField->isNew()) {
$valueToCompareTo += !empty($metaField->value) ? 1 : 0;
} else {
$valueToCompareTo += !empty($metaField->value) ? 0 : -1;
}
}
}
if (!$enabled) {
continue;
}
if (!$enabled && !empty($entity->_metafields_to_delete)) {
foreach ($entity->_metafields_to_delete as $metaFieldToDelete) {
if ($metaFieldToDelete['field'] === $permission_name) {
$valueToCompareTo += !empty($metaFieldToDelete->value) ? -1 : 0;
}
}
}
$valueToCompareTo = $permission_data['current'] + ($new ? 1 : 0);
if ($valueToCompareTo > $permission_data['limit']) {
return [
$permission_name =>
@ -168,18 +183,6 @@ class UsersTable extends AppTable
return $rules;
}
public function test()
{
$this->Roles = TableRegistry::get('Roles');
$role = $this->Roles->newEntity([
'name' => 'admin',
'perm_admin' => 1,
'perm_org_admin' => 1,
'perm_sync' => 1
]);
$this->Roles->save($role);
}
public function checkForNewInstance(): bool
{
if (empty($this->find()->first())) {
@ -263,7 +266,7 @@ class UsersTable extends AppTable
{
if (!empty(Configure::read('keycloak'))) {
$success = $this->handleUserUpdate($user);
//return $success !== false;
// return $success;
}
return true;
}

View File

@ -25,7 +25,7 @@ class Notification
foreach ($options as $key => $value) {
$this->{$key} = $value;
}
$this->validate();
$this->_validate();
}
public function get(): array
@ -36,7 +36,7 @@ class Notification
return null;
}
private function validate()
protected function _validate()
{
$validator = new Validator();

View File

@ -1,4 +1,4 @@
{
"version": "1.8",
"version": "1.9",
"application": "Cerebrate"
}

View File

@ -1077,6 +1077,7 @@ class BoostrapModal extends BootstrapGeneric
'bodyHtml' => false,
'footerHtml' => false,
'confirmText' => 'Confirm',
'confirmIcon' => false,
'cancelText' => 'Cancel',
'modalClass' => [''],
'headerClass' => [''],
@ -1218,6 +1219,7 @@ class BoostrapModal extends BootstrapGeneric
$buttonConfirm = (new BoostrapButton([
'variant' => $variant,
'text' => h($this->options['confirmText']),
'icon' => h($this->options['confirmIcon']),
'class' => 'modal-confirm-button',
'params' => [
// 'data-bs-dismiss' => $this->options['confirmFunction'] ? '' : 'modal',
@ -1953,7 +1955,7 @@ class BoostrapDropdownMenu extends BootstrapGeneric
'aria-expanded' => 'false',
]
];
$options = array_merge($this->options['toggle-button'], $defaultOptions);
$options = array_merge_recursive($this->options['toggle-button'], $defaultOptions);
return $this->btHelper->button($options);
}

View File

@ -28,6 +28,12 @@ echo $this->element('genericElements/IndexTable/index_table', [
'sort' => 'request_ip',
'data_path' => 'request_ip',
],
[
'name' => 'created',
'sort' => 'created',
'data_path' => 'created',
'element' => 'datetime'
],
[
'name' => __('Username'),
'sort' => 'user.username',

View File

@ -12,9 +12,9 @@ echo $this->element('genericElements/IndexTable/index_table', [
'type' => 'multi_select_actions',
'children' => [
[
'text' => __('Discard requests'),
'text' => __('Discard message'),
'variant' => 'danger',
'onclick' => 'discardRequests',
'onclick' => 'discardMessages',
]
],
'data' => [
@ -34,6 +34,10 @@ echo $this->element('genericElements/IndexTable/index_table', [
'data' => '',
'searchKey' => 'value',
'allowFilering' => true
],
[
'type' => 'table_action',
'table_setting_id' => 'inbox_index',
]
]
],
@ -49,6 +53,18 @@ echo $this->element('genericElements/IndexTable/index_table', [
'data_path' => 'created',
'element' => 'datetime'
],
[
'name' => 'severity',
'sort' => 'severity',
'data_path' => 'severity',
'element' => 'function',
'function' => function ($entry, $context) {
return $context->Bootstrap->badge([
'text' => $entry->severity_variant,
'variant' => $entry->severity_variant,
]);
}
],
[
'name' => 'scope',
'sort' => 'scope',
@ -76,14 +92,9 @@ echo $this->element('genericElements/IndexTable/index_table', [
'element' => 'user'
],
[
'name' => 'description',
'sort' => 'description',
'data_path' => 'description',
],
[
'name' => 'comment',
'sort' => 'comment',
'data_path' => 'comment',
'name' => 'message',
'sort' => 'message',
'data_path' => 'message',
],
],
'title' => __('Inbox'),
@ -105,7 +116,7 @@ echo $this->element('genericElements/IndexTable/index_table', [
'open_modal' => '/inbox/delete/[onclick_params_data_path]',
'modal_params_data_path' => 'id',
'icon' => 'trash',
'title' => __('Discard request')
'title' => __('Discard message')
],
]
]
@ -113,7 +124,7 @@ echo $this->element('genericElements/IndexTable/index_table', [
?>
<script>
function discardRequests(idList, selectedData, $table) {
function discardMessages(idList, selectedData, $table) {
const successCallback = function([data, modalObject]) {
UI.reload('/inbox/index', UI.getContainerForTable($table), $table)
}
@ -157,6 +168,7 @@ echo $this->element('genericElements/IndexTable/index_table', [
}
)
}
function handleMessageTable($modal, header, data) {
const $modalBody = $modal.find('.modal-body')
const $messageTable = $modalBody.find('table.message-table')

View File

@ -33,8 +33,8 @@ echo $this->element(
'path' => 'user_id',
],
[
'key' => 'description',
'path' => 'description',
'key' => 'message',
'path' => 'message',
],
[
'key' => 'comment',

View File

@ -30,6 +30,14 @@ echo $this->element('genericElements/IndexTable/index_table', [
'data_path' => 'multiple',
'field' => 'textarea'
],
[
'name' => __('Sane defaults'),
'data_path' => 'sane_default'
],
[
'name' => __('Values List'),
'data_path' => 'values_list'
],
[
'name' => __('Validation regex'),
'sort' => 'regex',

View File

@ -49,6 +49,18 @@ echo $this->element('genericElements/IndexTable/index_table', [
'data_path' => 'created',
'element' => 'datetime'
],
[
'name' => 'severity',
'sort' => 'severity',
'data_path' => 'severity',
'element' => 'function',
'function' => function ($entry, $context) {
return $context->Bootstrap->badge([
'text' => $entry->severity_variant,
'variant' => $entry->severity_variant,
]);
}
],
[
'name' => 'scope',
'sort' => 'scope',
@ -71,14 +83,9 @@ echo $this->element('genericElements/IndexTable/index_table', [
'element' => 'user'
],
[
'name' => 'description',
'sort' => 'description',
'data_path' => 'description',
],
[
'name' => 'comment',
'sort' => 'comment',
'data_path' => 'comment',
'name' => 'message',
'sort' => 'message',
'data_path' => 'message',
],
],
'title' => __('Outbox'),
@ -100,7 +107,7 @@ echo $this->element('genericElements/IndexTable/index_table', [
'open_modal' => '/outbox/delete/[onclick_params_data_path]',
'modal_params_data_path' => 'id',
'icon' => 'trash',
'title' => __('Discard request')
'title' => __('Discard message')
],
]
]

View File

@ -29,8 +29,8 @@ echo $this->element(
'path' => 'user_id',
],
[
'key' => 'description',
'path' => 'description',
'key' => 'message',
'path' => 'message',
],
[
'key' => 'comment',

View File

@ -26,7 +26,8 @@
],
[
'field' => 'comment',
'label' => 'Comment'
'label' => 'Comment',
'type' => 'textarea',
]
],
'submit' => [

View File

@ -18,7 +18,7 @@ echo $this->element(
],
[
'key' => __('Limit'),
'path' => 'limit'
'path' => 'max_occurrence'
],
[
'key' => __('Comment'),

View File

@ -1,4 +1,7 @@
<?php
use Cake\Core\Configure;
echo $this->element('genericElements/IndexTable/index_table', [
'data' => [
'data' => $data,
@ -92,6 +95,12 @@ echo $this->element('genericElements/IndexTable/index_table', [
'url' => '/user-settings/index?Users.id={{url_data}}',
'url_data_path' => 'id'
],
// [ // We might want to uncomment this at some point
// 'name' => __('Keycloak status'),
// 'element' => 'keycloak_status',
// 'data_path' => 'keycloak_status',
// 'requirements' => Configure::read('keycloak.enabled', false),
// ],
],
'title' => __('User index'),
'description' => __('The list of enrolled users in this Cerebrate instance. All of the users have or at one point had access to the system.'),

View File

@ -34,6 +34,20 @@ use Cake\Core\Configure;
echo '</div>';
echo '</div>';
echo '<div class="row g-1 mb-3">';
echo $this->Form->control('org_name', ['label' => __('Organisation Name'), 'class' => 'form-control']);
echo $this->Form->control('org_uuid', [
'label' => __('UUID'),
'class' => 'form-control form-control-sm mb-2',
'style' => 'font-size: 0.815rem;',
'div' => ['class' => 'test'],
'templates' => [
'inputContainer' => '<div class="input d-flex {{type}}{{required}}">{{content}}</div>',
'label' => '<label class="fw-light fs-7 me-1 align-self-center small-label-addon" {{attrs}}>{{text}}</label>',
],
]);
echo '</div>';
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']);
@ -42,4 +56,16 @@ use Cake\Core\Configure;
echo '</div>';
echo $this->Form->end();
?>
</div>
</div>
<style>
.text-input-addon-center {
display: flex;
flex: 0 0 0%;
align-items: center
}
.small-label-addon {
height: calc(1.5em + 0.5rem + 2px);
}
</style>

View File

@ -51,14 +51,22 @@ $fields = [
'scope' => 'individuals'
]
];
if ($keycloakConfig['enabled'] && $loggedUser['id'] == $entity['id']) {
if ($keycloakConfig['enabled']) {
$fields[] = [
'type' => 'generic',
'key' => __('Modify keycloak profile'),
'path' => 'username',
'url' => $kcurl,
'requirements' => false
'key' => __('Keycloak status'),
'type' => 'keycloakStatus',
'path' => 'keycloak_status',
'requirements' => !empty($keycloakConfig['enabled']),
];
if ($loggedUser['id'] == $entity['id']) {
$fields[] = [
'type' => 'generic',
'key' => __('Modify keycloak profile'),
'path' => 'username',
'url' => $kcurl,
'requirements' => false
];
}
}
echo $this->element(
'/genericElements/SingleViews/single_view',

View File

@ -0,0 +1,7 @@
<?php
if (!empty($element)) {
echo $this->Bootstrap->{$element}([
'text' => $text,
'variant' => $variant,
]);
}

View File

@ -1,14 +1,80 @@
<?php
$controlParams = [
'options' => $fieldData['options'],
'empty' => $fieldData['empty'] ?? false,
'value' => $fieldData['value'] ?? null,
'multiple' => $fieldData['multiple'] ?? false,
'disabled' => $fieldData['disabled'] ?? false,
'class' => ($fieldData['class'] ?? '') . ' formDropdown form-select',
'default' => ($fieldData['default'] ?? null)
];
if (!empty($fieldData['label'])) {
$controlParams['label'] = $fieldData['label'];
$seed = 's-' . mt_rand();
$controlParams = [
'type' => 'select',
'options' => $fieldData['options'],
'empty' => $fieldData['empty'] ?? false,
'value' => $fieldData['value'] ?? null,
'multiple' => $fieldData['multiple'] ?? false,
'disabled' => $fieldData['disabled'] ?? false,
'class' => ($fieldData['class'] ?? '') . ' formDropdown form-select',
'default' => $fieldData['default'] ?? '',
];
if (!empty($fieldData['field'])) { // used for multi meta-field form
$controlParams['field'] = $fieldData['field'];
}
if (!empty($fieldData['label'])) {
$controlParams['label'] = $fieldData['label'];
}
if ($controlParams['options'] instanceof \Cake\ORM\Query) {
$controlParams['options'] = $controlParams['options']->all()->toList();
}
if (in_array('_custom', array_keys($controlParams['options']))) {
$customInputValue = $this->Form->getSourceValue($fieldData['field']);
if (!in_array($customInputValue, $controlParams['options'])) {
$controlParams['options'] = array_map(function ($option) {
if (is_array($option) && $option['value'] == '_custom') {
$option[] = 'selected';
}
return $option;
}, $controlParams['options']);
} else {
$customInputValue = '';
}
echo $this->FormFieldMassage->prepareFormElement($this->Form, $controlParams, $fieldData);
$controlParams['class'] .= ' dropdown-custom-value' . "-$seed";
$adaptedField = $fieldData['field'] . '_custom';
$controlParams['templates']['formGroup'] = sprintf(
'<label class="col-sm-2 col-form-label form-label" {{attrs}}>{{label}}</label><div class="col-sm-10 multi-metafield-input-container"><div class="d-flex form-dropdown-with-freetext input-group">{{input}}{{error}}%s</div></div>',
sprintf('<input type="text" class="form-control custom-value" field="%s" value="%s">', h($adaptedField), h($customInputValue))
);
}
echo $this->FormFieldMassage->prepareFormElement($this->Form, $controlParams, $fieldData);
?>
<script>
(function() {
$(document).ready(function() {
const $select = $('select.dropdown-custom-value-<?= $seed ?>')
toggleFreetextSelectField($select[0]);
$select.attr('onclick', 'toggleFreetextSelectField(this)')
$select.parent().find('input.custom-value').attr('oninput', 'updateAssociatedSelect(this)')
updateAssociatedSelect($select.parent().find('input.custom-value')[0])
})
})()
function toggleFreetextSelectField(selectEl) {
const $select = $(selectEl)
const show = $select.find('option:selected').hasClass('custom-value')
const $container = $(selectEl).parent()
let $freetextInput = $container.find('input.custom-value')
if (show) {
$freetextInput.removeClass('d-none')
} else {
$freetextInput.addClass('d-none')
}
}
function updateAssociatedSelect(input) {
const $input = $(input)
const $select = $input.parent().find('select')
const $customOption = $select.find('option.custom-value')
$customOption.val($input.val())
}
</script>
<style>
form div.form-dropdown-with-freetext input.custom-value {
flex-grow: 3;
}
</style>

View File

@ -1,11 +1,10 @@
<?php
if (is_array($fieldData)) {
if (empty($fieldData['type'])) {
$fieldData['type'] = 'text';
}
$fieldTemplate = 'genericField';
if (file_exists(ROOT . '/templates/element/genericElements/Form/Fields/' . $fieldData['type'] . 'Field.php')) {
$fieldTemplate = $fieldData['type'] . 'Field';
if (!empty($fieldData['type'])) {
if (file_exists(ROOT . '/templates/element/genericElements/Form/Fields/' . $fieldData['type'] . 'Field.php')) {
$fieldTemplate = $fieldData['type'] . 'Field';
}
}
if (empty($fieldData['label'])) {
if (!isset($fieldData['label']) || $fieldData['label'] !== false) {

View File

@ -4,12 +4,12 @@
</h2>
<?= $formCreate ?>
<?= $ajaxFlashMessage ?>
<?php if (!empty($data['description'])) : ?>
<?php if (!empty($data['description']) || !empty($data['descriptionHtml'])) : ?>
<div class="pb-3 fw-light">
<?= h($data['description']) ?>
<?= !empty($data['descriptionHtml']) ? $data['descriptionHtml'] : h($data['description']) ?>
</div>
<?php endif; ?>
<div class="panel col-lg-8">
<div class="panel col-lg-12">
<?= $fieldsString ?>
</div>

View File

@ -1,6 +1,6 @@
<?php if (!empty($data['description'])) : ?>
<?php if (!empty($data['description']) || !empty($data['descriptionHtml'])) : ?>
<div class="pb-3 fw-light">
<?= h($data['description']) ?>
<?= !empty($data['descriptionHtml']) ? $data['descriptionHtml'] : h($data['description']) ?>
</div>
<?php endif; ?>
<?= $ajaxFlashMessage ?>

View File

@ -31,15 +31,16 @@ foreach ($metaTemplate->meta_template_fields as $metaTemplateField) {
$metaField = reset($metaTemplateField->metaFields);
$fieldData = [
'label' => $metaTemplateField->label,
'type' => $metaTemplateField->formType,
];
if (!empty($metaTemplateField->formOptions)) {
$fieldData = array_merge_recursive($fieldData, $metaTemplateField->formOptions);
}
if (isset($metaField->id)) {
$fieldData['field'] = sprintf('MetaTemplates.%s.meta_template_fields.%s.metaFields.%s.value', $metaField->meta_template_id, $metaField->meta_template_field_id, $metaField->id);
} else {
$fieldData['field'] = sprintf('MetaTemplates.%s.meta_template_fields.%s.metaFields.%s.value', $metaField->meta_template_id, $metaField->meta_template_field_id, array_key_first($metaTemplateField->metaFields));
}
if ($metaTemplateField->type === 'boolean') {
$fieldData['type'] = 'checkbox';
}
$this->Form->setTemplates($backupTemplates);
$fieldsHtml .= $this->element(
'genericElements/Form/fieldScaffold',
@ -66,9 +67,10 @@ foreach ($metaTemplate->meta_template_fields as $metaTemplateField) {
$fieldData = [
'field' => sprintf('MetaTemplates.%s.meta_template_fields.%s.metaFields.new.0', $metaTemplateField->meta_template_id, $metaTemplateField->id),
'label' => $metaTemplateField->label,
'type' => $metaTemplateField->formType,
];
if ($metaTemplateField->type === 'boolean') {
$fieldData['type'] = 'checkbox';
if (!empty($metaTemplateField->formOptions)) {
$fieldData = array_merge_recursive($fieldData, $metaTemplateField->formOptions);
}
$fieldsHtml .= $this->element(
'genericElements/Form/fieldScaffold',
@ -80,4 +82,7 @@ foreach ($metaTemplate->meta_template_fields as $metaTemplateField) {
}
}
}
echo $fieldsHtml;
$fieldContainer = $this->Bootstrap->genNode('div', [
'class' => [],
], $fieldsHtml);
echo $fieldContainer;

View File

@ -45,6 +45,8 @@ $seed = 'mfb-' . mt_rand();
$clonedContainer
.removeClass('has-error')
.find('.error-message ').remove()
$clonedContainer
.find('label.form-label').text('')
const $clonedInput = $clonedContainer.find('input, select')
if ($clonedInput.length > 0) {
const injectedTemplateId = $clicked.closest('.multi-metafields-container').find('.new-metafield').length
@ -54,18 +56,25 @@ $seed = 'mfb-' . mt_rand();
}
}
function adjustClonedInputAttr($input, injectedTemplateId) {
let explodedPath = $input.attr('field').split('.').splice(0, 5)
explodedPath.push('new', injectedTemplateId)
dottedPathStr = explodedPath.join('.')
brackettedPathStr = explodedPath.map((elem, i) => {
return i == 0 ? elem : `[${elem}]`
}).join('')
$input.attr('id', dottedPathStr)
.attr('field', dottedPathStr)
.attr('name', brackettedPathStr)
.val('')
.removeClass('is-invalid')
function adjustClonedInputAttr($inputs, injectedTemplateId) {
$inputs.each(function() {
const $input = $(this)
let explodedPath = $input.attr('field').split('.').splice(0, 5)
explodedPath.push('new', injectedTemplateId)
dottedPathStr = explodedPath.join('.')
brackettedPathStr = explodedPath.map((elem, i) => {
return i == 0 ? elem : `[${elem}]`
}).join('')
const attrs = ['id', 'field', 'name']
attrs.forEach((attr) => {
if ($input.attr(attr) !== undefined) {
$input.attr(attr, attr === 'name' ? brackettedPathStr : dottedPathStr)
}
})
$input
.val('')
.removeClass('is-invalid')
})
}
})()
</script>

View File

@ -22,6 +22,7 @@ if (!empty($metaFieldsEntities)) {
$metaFieldsEntity->meta_template_field_id,
$metaFieldsEntity->id
),
'type' => $metaTemplateField->formType,
];
if($metaFieldsEntity->isNew()) {
$fieldData['field'] = sprintf(
@ -33,7 +34,10 @@ if (!empty($metaFieldsEntities)) {
$fieldData['class'] = 'new-metafield';
}
if ($labelPrintedOnce) { // Only the first input can have a label
$fieldData['label'] = false;
$fieldData['label'] = ['text' => ''];
}
if ($metaTemplateField->formType === 'dropdown') {
$fieldData = array_merge_recursive($fieldData, $metaTemplateField->formOptions);
}
$labelPrintedOnce = true;
$fieldsHtml .= $this->element(
@ -49,14 +53,19 @@ if (!empty($metaTemplateField) && !empty($multiple)) { // Add multiple field but
$metaTemplateField->label = Inflector::humanize($metaTemplateField->field);
$emptyMetaFieldInput = '';
if (empty($metaFieldsEntities)) { // Include editable field for meta-template not containing a meta-field
$fieldData = [
'label' => $metaTemplateField->label,
'field' => sprintf('MetaTemplates.%s.meta_template_fields.%s.metaFields.new.0', $metaTemplateField->meta_template_id, $metaTemplateField->id),
'class' => 'new-metafield',
'type' => $metaTemplateField->formType,
];
if ($metaTemplateField->formType === 'dropdown') {
$fieldData = array_merge_recursive($fieldData, $metaTemplateField->formOptions);
}
$emptyMetaFieldInput = $this->element(
'genericElements/Form/fieldScaffold',
[
'fieldData' => [
'label' => $metaTemplateField->label,
'field' => sprintf('MetaTemplates.%s.meta_template_fields.%s.metaFields.new.0', $metaTemplateField->meta_template_id, $metaTemplateField->id),
'class' => 'new-metafield',
],
'fieldData' => $fieldData,
'form' => $form,
]
);

View File

@ -0,0 +1,14 @@
<?php
$data = $this->Hash->get($row, $field['data_path']);
if (is_null($data)) {
echo '';
} else if (!empty($data['require_update'])) {
echo sprintf(
'<span data-bs-toggle="tooltip" data-bs-title="%s">%s</span>',
sprintf('Fields having differences: %s', (implode(', ', array_keys($data['differences'])))),
$this->Bootstrap->icon('times', ['class' => 'text-danger', ])
);
} else {
echo $this->Bootstrap->icon('check', ['class' => 'text-success',]);
}
?>

View File

@ -25,10 +25,12 @@ if (!empty($requestedMetaFields)) { // Create mapping for new index table fields
foreach ($requestedMetaFields as $requestedMetaField) {
$template_id = $requestedMetaField['template_id'];
$meta_template_field_id = $requestedMetaField['meta_template_field_id'];
$viewElementCandidate = $meta_templates[$template_id]['meta_template_fields'][$meta_template_field_id]['index_type'];
$viewElementCandidatePath = '/genericElements/IndexTable/Fields/' . $viewElementCandidate;
$newMetaFields[] = [
'name' => $meta_templates[$template_id]['meta_template_fields'][$meta_template_field_id]['field'],
'data_path' => "MetaTemplates.{$template_id}.meta_template_fields.{$meta_template_field_id}.metaFields.{n}.value",
'element' => 'generic_field',
'element' => $this->elementExists($viewElementCandidatePath) ? $viewElementCandidate : 'generic_field',
'_metafield' => true,
'_automatic_field' => true,
];

View File

@ -21,7 +21,7 @@
} else {
$currentFilteringContext = $filteringContext['filterCondition'];
}
$contextArray[] = [
$contextItem = [
'active' => (
(
$currentQuery == $currentFilteringContext && // query conditions match
@ -42,9 +42,17 @@
"#table-container-${tableRandomValue}",
"#table-container-${tableRandomValue} table.table",
],
'text' => $filteringContext['label'],
'class' => 'btn-sm'
];
if (!empty($filteringContext['viewElement'])) {
$contextItem['html'] = $this->element(
$filteringContext['viewElement'],
$filteringContext['viewElementParams'] ?? []
);
} else {
$contextItem['text'] = $filteringContext['label'];
}
$contextArray[] = $contextItem;
}
$dataGroup = [

View File

@ -8,6 +8,7 @@ if (empty($data['table_setting_id']) && empty($model)) {
$data['table_setting_id'] = !empty($data['table_setting_id']) ? $data['table_setting_id'] : IndexSetting::getIDFromTable($model);
$tableSettings = IndexSetting::getTableSetting($loggedUser, $data['table_setting_id']);
$compactDisplay = !empty($tableSettings['compact_display']);
$numberOfElement = $tableSettings['number_of_element'] ?? 20;
$availableColumnsHtml = $this->element('/genericElements/ListTopBar/group_table_action/hiddenColumns', [
'table_data' => $table_data,
@ -51,7 +52,13 @@ $compactDisplayHtml = $this->element('/genericElements/ListTopBar/group_table_ac
'table_data' => $table_data,
'tableSettings' => $tableSettings,
'table_setting_id' => $data['table_setting_id'],
'compactDisplay' => $compactDisplay
'compactDisplay' => $compactDisplay,
]);
$numberOfElementHtml = $this->element('/genericElements/ListTopBar/group_table_action/numberOfElement', [
'table_data' => $table_data,
'tableSettings' => $tableSettings,
'table_setting_id' => $data['table_setting_id'],
'numberOfElement' => $numberOfElement,
]);
?>
<?php if (!isset($data['requirement']) || $data['requirement']) : ?>
@ -63,6 +70,7 @@ $compactDisplayHtml = $this->element('/genericElements/ListTopBar/group_table_ac
'toggle-button' => [
'icon' => 'sliders-h',
'variant' => 'primary',
'class' => ['table_setting_dropdown_button'],
],
'submenu_alignment' => 'end',
'submenu_direction' => 'start',
@ -79,8 +87,22 @@ $compactDisplayHtml = $this->element('/genericElements/ListTopBar/group_table_ac
],
[
'html' => $compactDisplayHtml,
],
[
'html' => $numberOfElementHtml,
]
]
]);
?>
<?php endif; ?>
<script>
$(document).ready(function() {
const dropdownBtn = document.querySelector('button.table_setting_dropdown_button')
dropdownBtn.addEventListener('hidden.bs.dropdown', function() {
const $dropdownBtn = $(this)
const debouncedFunctions = $dropdownBtn.data('debouncedFunctions')
firePendingDebouncedFunctions(dropdownBtn)
})
})
</script>

View File

@ -41,6 +41,7 @@ $compactDisplayInputSeed = 'seed-' . mt_rand();
const $container = $dropdownMenu.closest('div[id^="table-container-"]')
const $table = $container.find(`table[data-table-random-value="${tableRandomValue}"]`)
toggleCompactState($checkbox[0].checked, $table)
registerDebouncedFunction($container, debouncedCompactSaver)
})
})()
</script>

View File

@ -40,7 +40,8 @@ echo $availableColumnsHtml;
const debouncedHiddenColumnSaverWithReload = debounce(mergeAndSaveSettingsWithReload, 2000)
function attachListeners() {
let debouncedFunctionWithReload = false, debouncedFunction = false // used to flush debounce function if dropdown menu gets closed
let debouncedFunctionWithReload = false,
debouncedFunction = false // used to flush debounce function if dropdown menu gets closed
$('form.visible-column-form, form.visible-meta-column-form').find('input').change(function() {
const $dropdownMenu = $(this).closest(`[data-table-random-value]`)
const tableRandomValue = $dropdownMenu.attr('data-table-random-value')
@ -108,24 +109,6 @@ echo $availableColumnsHtml;
return tableSetting
}
function mergeAndSaveSettingsWithReload(table_setting_id, tableSettings, $table) {
mergeAndSaveSettings(table_setting_id, tableSettings, false).then((apiResult) => {
const theToast = UI.toast({
variant: 'success',
title: apiResult.message,
bodyHtml: $('<div/>').append(
$('<span/>').text('<?= __('The table needs to be reloaded for the new fields to be included.') ?>'),
$('<button/>').addClass(['btn', 'btn-primary', 'btn-sm', 'ms-3']).text('<?= __('Reload table') ?>').click(function() {
const reloadUrl = $table.data('reload-url');
UI.reload(reloadUrl, $table.closest('div[id^="table-container-"]'), $(this)).then(() => {
theToast.removeToast()
})
}),
),
})
})
}
$(document).ready(function() {
addSupportOfNestedDropdown();
const $form = $('form.visible-column-form, form.visible-meta-column-form')
@ -138,6 +121,8 @@ echo $availableColumnsHtml;
toggleColumn(this.getAttribute('data-columnname'), this.checked, $table)
})
attachListeners()
registerDebouncedFunction($container, debouncedHiddenColumnSaver)
registerDebouncedFunction($container, debouncedHiddenColumnSaverWithReload)
})
})()
</script>

View File

@ -0,0 +1,42 @@
<?php
$numberOfElementSelectSeed = 'seed-' . mt_rand();
?>
<label class="dropdown-item d-flex align-items-center cursor-pointer" href="#" for="<?= $numberOfElementSelectSeed ?>">
<span class="fw-bold">#</span>
<span class="ms-2"><?= __('Show') ?></span>
<select id="<?= $numberOfElementSelectSeed ?>" class="select-number-of-element form-select ms-auto" style="width: 5em;">
<option value="20" <?= $numberOfElement == 20 ? 'selected' : '' ?>><?= __('20') ?></option>
<option value="50" <?= $numberOfElement == 50 ? 'selected' : '' ?>><?= __('50') ?></option>
<option value="100" <?= $numberOfElement == 100 ? 'selected' : '' ?>><?= __('100') ?></option>
<option value="200" <?= $numberOfElement == 200 ? 'selected' : '' ?>><?= __('200') ?></option>
</select>
</label>
<script>
(function() {
const debouncedNumberElementSaver = debounce(mergeAndSaveSettingsWithReload, 2000)
$('#<?= $numberOfElementSelectSeed ?>').change(function() {
const $dropdownMenu = $(this).closest('.dropdown')
const tableRandomValue = $dropdownMenu.attr('data-table-random-value')
const $container = $dropdownMenu.closest('div[id^="table-container-"]')
const $table = $container.find(`table[data-table-random-value="${tableRandomValue}"]`)
const table_setting_id = $dropdownMenu.data('table_setting_id');
let newTableSettings = {}
newTableSettings[table_setting_id] = {
'number_of_element': $(this).val()
}
debouncedNumberElementSaver(table_setting_id, newTableSettings, $table)
})
$(document).ready(function() {
const $select = $('#<?= $numberOfElementSelectSeed ?>')
const $dropdownMenu = $select.closest('.dropdown')
const tableRandomValue = $dropdownMenu.attr('data-table-random-value')
const $container = $dropdownMenu.closest('div[id^="table-container-"]')
const $table = $container.find(`table[data-table-random-value="${tableRandomValue}"]`)
registerDebouncedFunction($container, debouncedNumberElementSaver)
})
})()
</script>

View File

@ -0,0 +1,21 @@
<?php
use Cake\Utility\Hash;
$value = Hash::get($data, $field['path']);
$differencesRearranged = array_map(function($difference) {
return [
__('Local: {0}', h($difference['cerebrate'])),
__('Keycloak: {0}', h($difference['keycloak'])),
];
}, $value['differences']);
if (!empty($value['require_update'])) {
echo sprintf(
'<div class="alert alert-warning"><div>%s</div>%s</div>',
$this->Bootstrap->icon('exclamation-triangle') . __(' This user is not synchronised with Keycloak. Differences:'),
$this->Html->nestedList($differencesRearranged, ['class' => ''])
);
} else {
echo $this->Bootstrap->icon('check', ['class' => 'text-success',]);
}
?>

View File

@ -21,7 +21,7 @@ $variant = empty($notification['variant']) ? 'primary' : $notification['variant'
</span>
<?php endif; ?>
<span class="notification-text-container">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex justify-content-between align-items-center gap-2">
<span class="notification-title text-truncate"><?= $this->ValueGetter->get($notification['text']) ?></span>
<?php if (!empty($notification['datetime'])) : ?>
<small id="<?= $seed ?>" class="notification-date text-muted fw-light"><?= h($notification['datetime']->format('Y-m-d\TH:i:s')) ?></small>

View File

@ -7,6 +7,7 @@ $tableItems = array_map(function ($fieldName) {
'fieldname' => $fieldName,
];
}, $filters);
$formTypeMap = $this->Form->getConfig('typeMap');
$filteringForm = $this->Bootstrap->table(
[
@ -23,11 +24,19 @@ $filteringForm = $this->Bootstrap->table(
}
],
[
'key' => 'operator', 'label' => __('Operator'), 'formatter' => function ($field, $row) {
'key' => 'operator', 'label' => __('Operator'), 'formatter' => function ($field, $row) use ($typeMap) {
$fieldName = $row['fieldname'];
$type = $typeMap[$fieldName] ?? 'text';
$options = [
sprintf('<option value="%s">%s</option>', '=', '='),
sprintf('<option value="%s">%s</option>', '!=', '!='),
];
if ($type === 'datetime') {
$options = [
sprintf('<option value="%s">%s</option>', '>=', '>='),
sprintf('<option value="%s">%s</option>', '<=', '<='),
];
}
return sprintf('<select class="fieldOperator form-select form-select-sm">%s</select>', implode('', $options));
}
],
@ -38,8 +47,21 @@ $filteringForm = $this->Bootstrap->table(
__('Value'),
sprintf('<sup class="fa fa-info" title="%s"><sup>', __('Supports strict matches and LIKE matches with the `%` character.&#10;Example: `%.com`'))
),
'formatter' => function ($field, $row) {
return sprintf('<input type="text" class="fieldValue form-control form-control-sm">');
'formatter' => function ($field, $row) use ($typeMap, $formTypeMap) {
$fieldName = $row['fieldname'];
$formType = $formTypeMap[$typeMap[$fieldName]] ?? 'text';
$this->Form->setTemplates([
'formGroup' => '<div class="col-sm-10">{{input}}</div>',
]);
return $this->element('genericElements/Form/fieldScaffold', [
'fieldData' => [
'field' => $fieldName,
'type' => $formType,
'label' => '',
'class' => 'fieldValue form-control-sm'
],
'params' => []
]);
}
],
],
@ -102,8 +124,8 @@ echo $this->Bootstrap->modal([
$rows.each(function() {
const rowData = getDataFromRow($(this))
let fullFilter = rowData['name']
if (rowData['operator'] == '!=') {
fullFilter += ' !='
if (rowData['operator'] != '=') {
fullFilter += ` ${rowData['operator']}`
}
if (rowData['value'].length > 0) {
activeFilters[fullFilter] = rowData['value']
@ -111,14 +133,13 @@ echo $this->Bootstrap->modal([
})
if (modalObject.$modal.find('table.indexMetaFieldsFilteringTable').length > 0) {
let metaFieldFilters = modalObject.$modal.find('table.indexMetaFieldsFilteringTable')[0].getFiltersFunction()
// activeFilters['filteringMetaFields'] = metaFieldFilters !== undefined ? metaFieldFilters : [];
metaFieldFilters = metaFieldFilters !== undefined ? metaFieldFilters : []
for (let [metaFieldPath, metaFieldValue] of Object.entries(metaFieldFilters)) {
activeFilters[metaFieldPath] = metaFieldValue
}
}
$selectTag = modalObject.$modal.find('.tag-container select.select2-input')
activeFilters['filteringTags'] = $selectTag.select2('data').map(tag => tag.text)
activeFilters['filteringTags'] = $selectTag.length > 0 ? $selectTag.select2('data').map(tag => tag.text) : []
const searchParam = jQuery.param(activeFilters);
const url = `/${controller}/${action}?${searchParam}`
@ -138,8 +159,8 @@ echo $this->Bootstrap->modal([
for (let [field, value] of Object.entries(activeFilters)) {
const fieldParts = field.split(' ')
let operator = '='
if (fieldParts.length == 2 && fieldParts[1] == '!=') {
operator = '!='
if (fieldParts.length == 2) {
operator = fieldParts[1]
field = fieldParts[0]
} else if (fieldParts.length > 2) {
console.error('Field contains multiple spaces. ' + field)
@ -167,14 +188,24 @@ echo $this->Bootstrap->modal([
return $(this).data('fieldname') == field
}).closest('tr')
$row.find('.fieldOperator').val(operator)
$row.find('.fieldValue').val(value)
const $formElement = $row.find('.fieldValue');
if ($formElement.attr('type') === 'datetime-local') {
$formElement.val(moment(value).format('yyyy-MM-DDThh:mm:ss'))
} else {
$formElement.val(value)
}
}
function getDataFromRow($row) {
const rowData = {};
rowData['name'] = $row.find('td > span.fieldName').data('fieldname')
rowData['operator'] = $row.find('select.fieldOperator').val()
rowData['value'] = $row.find('input.fieldValue').val()
const $formElement = $row.find('.fieldValue');
if ($formElement.attr('type') === 'datetime-local') {
rowData['value'] = $formElement.val().length > 0 ? moment($formElement.val()).toISOString() : $formElement.val()
} else {
rowData['value'] = $formElement.val()
}
return rowData
}

View File

@ -88,7 +88,7 @@ $sidebarOpen = $loggedUser->user_settings_by_name_with_fallback['ui.sidebar.expa
</main>
</div>
<div id="mainModal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="mediumModalLabel" aria-hidden="true"></div>
<div id="mainToastContainer" style="position: absolute; top: 15px; right: 15px; z-index: 1080"></div>
<div id="mainToastContainer" class="main-toast-container"></div>
<div id="mainModalContainer"></div>
</body>

View File

@ -207,6 +207,15 @@ main.content {
min-width: 25px;
}
.main-toast-container {
position: absolute;
top: 15px;
right: 15px;
z-index: 1080;
display: flex;
flex-direction: column;
row-gap: 0.5em;
}
/* sidebar */
.sidebar-wrapper {
@ -537,9 +546,10 @@ ul.sidebar-elements > li.category > span.category-divider > hr {
}
.navbar .right-navbar .notification-menu a.notification-item .notification-text-container {
max-width: calc(100% - 40px - 0.25rem);
flex-grow: 1;
}
.navbar .right-navbar .notification-menu a.notification-item .notification-title {
max-width: calc(100% - 90px);
max-width: 300px;
}
.navbar .right-navbar .notification-menu a.notification-item .notification-date {
max-width: 90px;

View File

@ -381,7 +381,7 @@ class AJAXApi {
this.provideSuccessFeedback(data, {}, skipFeedback)
toReturn = data;
} else {
this.provideFailureFeedback(data, {}, skipFeedback)
this.provideFailureFeedback(data, {}, false)
feedbackShown = true
this.injectFormValidationFeedback(form, data.errors)
toReturn = Promise.reject(data.errors);

View File

@ -350,18 +350,16 @@ class Toaster {
$toast.attr('id', options.id)
}
$toast.addClass('toast-' + options.variant)
if (options.title !== false || options.titleHtml !== false || options.muted !== false || options.mutedHtml !== false) {
if (options.title !== false || options.titleHtml !== false || options.muted !== false || options.mutedHtml !== false || options.closeButton) {
var $toastHeader = $('<div class="toast-header"/>')
$toastHeader.addClass('toast-' + options.variant)
if (options.title !== false || options.titleHtml !== false) {
var $toastHeaderText
if (options.titleHtml !== false) {
$toastHeaderText = $('<div class="me-auto"/>').html(options.titleHtml);
} else {
$toastHeaderText = $('<strong class="me-auto"/>').text(options.title)
}
$toastHeader.append($toastHeaderText)
let $toastHeaderText = $('<span class="me-auto"/>')
if (options.titleHtml !== false) {
$toastHeaderText = $('<div class="me-auto"/>').html(options.titleHtml);
} else if (options.title !== false) {
$toastHeaderText = $('<strong class="me-auto"/>').text(options.title)
}
$toastHeader.append($toastHeaderText)
if (options.muted !== false || options.mutedHtml !== false) {
var $toastHeaderMuted
if (options.mutedHtml !== false) {

View File

@ -67,14 +67,21 @@ function saveAndUpdateSetting(statusNode, $input, settingName, settingValue) {
settingValue = JSON.stringify(settingValue)
}
saveSetting(statusNode, settingName, settingValue).then((result) => {
window.settingsFlattened[settingName] = result.data
if ($input.attr('type') == 'checkbox') {
$input.prop('checked', result.data.value == true)
} else {
$input.val(result.data.value)
}
updateSettingValue($input, settingName, result.data)
}).catch((e) => {
updateSettingValue($input, settingName, window.settingsFlattened[settingName])
}).finally(() => {
handleSettingValueChange($input)
}).catch((e) => { })
})
}
function updateSettingValue($input, settingName, settingValue) {
window.settingsFlattened[settingName] = settingValue
if ($input.attr('type') == 'checkbox') {
$input.prop('checked', settingValue.value == true)
} else {
$input.val(settingValue.value)
}
}
function handleSettingValueChange($input) {

View File

@ -10,6 +10,41 @@ function mergeAndSaveSettings(table_setting_id, newTableSettings, automaticFeedb
})
}
function mergeAndSaveSettingsWithReload(table_setting_id, tableSettings, $table) {
mergeAndSaveSettings(table_setting_id, tableSettings, false).then((apiResult) => {
const theToast = UI.toast({
variant: 'success',
title: apiResult.message,
bodyHtml: $('<div/>').append(
$('<span/>').text('The table needs to be reloaded for the new fields to be included.'),
$('<button/>').addClass(['btn', 'btn-primary', 'btn-sm', 'ms-3']).text('Reload table').click(function () {
const reloadUrl = $table.data('reload-url');
UI.reload(reloadUrl, $table.closest('div[id^="table-container-"]'), $(this)).then(() => {
theToast.removeToast()
})
}),
),
})
})
}
function registerDebouncedFunction($container, fn) {
$dropdownButton = $container.find('button.table_setting_dropdown_button')
if ($dropdownButton.data('debouncedFunctions') === undefined) {
$dropdownButton.data('debouncedFunctions', [])
}
$dropdownButton.data('debouncedFunctions').push(fn)
}
function firePendingDebouncedFunctions(dropdownBtn) {
$dropdownButton = $(dropdownBtn)
if ($dropdownButton.data('debouncedFunctions') !== undefined) {
$dropdownButton.data('debouncedFunctions').forEach(function (fn) {
fn.flush()
})
}
}
function mergeNewTableSettingsIntoOld(table_setting_id, oldTableSettings, newTableSettings) {
// Merge recursively
tableSettings = Object.assign({}, oldTableSettings, newTableSettings)