diff --git a/config/Migrations/20221025000000_inbox_severity.php b/config/Migrations/20221025000000_inbox_severity.php new file mode 100644 index 0000000..d452856 --- /dev/null +++ b/config/Migrations/20221025000000_inbox_severity.php @@ -0,0 +1,45 @@ +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(); + } + } +} diff --git a/config/Migrations/20221028000000_MetaFieldSaneDefault.php b/config/Migrations/20221028000000_MetaFieldSaneDefault.php new file mode 100644 index 0000000..b59149c --- /dev/null +++ b/config/Migrations/20221028000000_MetaFieldSaneDefault.php @@ -0,0 +1,34 @@ +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(); + } + } +} diff --git a/libraries/default/InboxProcessors/GenericInboxProcessor.php b/libraries/default/InboxProcessors/GenericInboxProcessor.php index 1ca84ea..fd2e09d 100644 --- a/libraries/default/InboxProcessors/GenericInboxProcessor.php +++ b/libraries/default/InboxProcessors/GenericInboxProcessor.php @@ -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( diff --git a/libraries/default/InboxProcessors/LocalToolInboxProcessor.php b/libraries/default/InboxProcessors/LocalToolInboxProcessor.php index 2dacab8..b2d84b0 100644 --- a/libraries/default/InboxProcessors/LocalToolInboxProcessor.php +++ b/libraries/default/InboxProcessors/LocalToolInboxProcessor.php @@ -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.'); } diff --git a/libraries/default/InboxProcessors/NotificationInboxProcessor.php b/libraries/default/InboxProcessors/NotificationInboxProcessor.php new file mode 100644 index 0000000..081b424 --- /dev/null +++ b/libraries/default/InboxProcessors/NotificationInboxProcessor.php @@ -0,0 +1,97 @@ +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; + } +} \ No newline at end of file diff --git a/libraries/default/InboxProcessors/SynchronisationInboxProcessor.php b/libraries/default/InboxProcessors/SynchronisationInboxProcessor.php index 2a243cd..d95154e 100644 --- a/libraries/default/InboxProcessors/SynchronisationInboxProcessor.php +++ b/libraries/default/InboxProcessors/SynchronisationInboxProcessor.php @@ -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'); } diff --git a/libraries/default/InboxProcessors/UserInboxProcessor.php b/libraries/default/InboxProcessors/UserInboxProcessor.php index abb9550..96656c1 100644 --- a/libraries/default/InboxProcessors/UserInboxProcessor.php +++ b/libraries/default/InboxProcessors/UserInboxProcessor.php @@ -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'); } diff --git a/libraries/default/InboxProcessors/templates/Notification/DataChange.php b/libraries/default/InboxProcessors/templates/Notification/DataChange.php new file mode 100644 index 0000000..952fae1 --- /dev/null +++ b/libraries/default/InboxProcessors/templates/Notification/DataChange.php @@ -0,0 +1,59 @@ +%s', + 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( + '
%s
+
+
+

%s

+
%s
+
%s
+
+
', + $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', +]); +?> + diff --git a/libraries/default/OutboxProcessors/BroodsOutboxProcessor.php b/libraries/default/OutboxProcessors/BroodsOutboxProcessor.php index 9bf79ae..8bde905 100644 --- a/libraries/default/OutboxProcessors/BroodsOutboxProcessor.php +++ b/libraries/default/OutboxProcessors/BroodsOutboxProcessor.php @@ -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'); } diff --git a/libraries/default/OutboxProcessors/GenericOutboxProcessor.php b/libraries/default/OutboxProcessors/GenericOutboxProcessor.php index c230465..6cd9cfb 100644 --- a/libraries/default/OutboxProcessors/GenericOutboxProcessor.php +++ b/libraries/default/OutboxProcessors/GenericOutboxProcessor.php @@ -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); diff --git a/plugins/Tags/src/Model/Behavior/TagBehavior.php b/plugins/Tags/src/Model/Behavior/TagBehavior.php index 5772d82..5d69ed2 100644 --- a/plugins/Tags/src/Model/Behavior/TagBehavior.php +++ b/plugins/Tags/src/Model/Behavior/TagBehavior.php @@ -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); } diff --git a/src/Command/SummaryCommand.php b/src/Command/SummaryCommand.php index 721d885..cf5debf 100644 --- a/src/Command/SummaryCommand.php +++ b/src/Command/SummaryCommand.php @@ -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)) { diff --git a/src/Controller/AppController.php b/src/Controller/AppController.php index 9e8aef8..238dc46 100644 --- a/src/Controller/AppController.php +++ b/src/Controller/AppController.php @@ -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.')); } diff --git a/src/Controller/AuditLogsController.php b/src/Controller/AuditLogsController.php index fd1be94..e3e4e0c 100644 --- a/src/Controller/AuditLogsController.php +++ b/src/Controller/AuditLogsController.php @@ -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']; diff --git a/src/Controller/Component/CRUDComponent.php b/src/Controller/Component/CRUDComponent.php index 1808f42..8d2f08f 100644 --- a/src/Controller/Component/CRUDComponent.php +++ b/src/Controller/Component/CRUDComponent.php @@ -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; + } } diff --git a/src/Controller/Component/Navigation/Inbox.php b/src/Controller/Component/Navigation/Inbox.php index 54da6c2..78eba2e 100644 --- a/src/Controller/Component/Navigation/Inbox.php +++ b/src/Controller/Component/Navigation/Inbox.php @@ -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'], diff --git a/src/Controller/Component/Navigation/Outbox.php b/src/Controller/Component/Navigation/Outbox.php index 8926ac4..39d4717 100644 --- a/src/Controller/Component/Navigation/Outbox.php +++ b/src/Controller/Component/Navigation/Outbox.php @@ -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'], diff --git a/src/Controller/InboxController.php b/src/Controller/InboxController.php index 0d07fb9..2abfb47 100644 --- a/src/Controller/InboxController.php +++ b/src/Controller/InboxController.php @@ -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); diff --git a/src/Controller/OrganisationsController.php b/src/Controller/OrganisationsController.php index 3106593..93ddfb9 100644 --- a/src/Controller/OrganisationsController.php +++ b/src/Controller/OrganisationsController.php @@ -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, diff --git a/src/Controller/OutboxController.php b/src/Controller/OutboxController.php index 90db5ba..ad0033c 100644 --- a/src/Controller/OutboxController.php +++ b/src/Controller/OutboxController.php @@ -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) diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index 8de4c92..771672d 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -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)) { @@ -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'); diff --git a/src/Model/Behavior/AuthKeycloakBehavior.php b/src/Model/Behavior/AuthKeycloakBehavior.php index 0a485dd..71af07b 100644 --- a/src/Model/Behavior/AuthKeycloakBehavior.php +++ b/src/Model/Behavior/AuthKeycloakBehavior.php @@ -284,27 +284,34 @@ 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) { + $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; + } + + public function getParsedKeycloakUser(): array { $response = $this->restApiRequest('%s/admin/realms/%s/users', [], 'get'); $keycloakUsers = json_decode($response->getStringBody(), true); @@ -325,23 +332,25 @@ 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 @@ -387,6 +396,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 + { + + $cEnabled = $keycloakUser['enabled'] == $user['disabled']; + $cFn = $keycloakUser['firstName'] !== $user['individual']['first_name']; + $Ln = $keycloakUser['lastName'] !== $user['individual']['last_name']; + $cEmail = $keycloakUser['email'] !== $user['individual']['email']; + $cRolename = (empty($keycloakUser['attributes']['role_name']) || $keycloakUser['attributes']['role_name'] !== $user['role']['name']); + $cRoleuuid = (empty($keycloakUser['attributes']['role_uuid']) || $keycloakUser['attributes']['role_uuid'] !== $user['role']['uuid']); + $cOrgname = (empty($keycloakUser['attributes']['org_name']) || $keycloakUser['attributes']['org_name'] !== $user['organisation']['name']); + $cOrguuid = (empty($keycloakUser['attributes']['org_uuid']) || $keycloakUser['attributes']['org_uuid'] !== $user['organisation']['uuid']); + if ($cEnabled || $cFn || $Ln || $cEmail || $cRolename || $cRoleuuid || $cOrgname || $cOrguuid) { + if ($cEnabled) { + $differences['enabled'] = ['kc' => $keycloakUser['enabled'], 'cerebrate' => $user['disabled']]; + } + if ($cFn) { + $differences['first_name'] = ['kc' => $keycloakUser['firstName'], 'cerebrate' => $user['individual']['first_name']]; + } + if ($Ln) { + $differences['last_name'] = ['kc' => $keycloakUser['lastName'], 'cerebrate' => $user['individual']['last_name']]; + } + if ($cEmail) { + $differences['email'] = ['kc' => $keycloakUser['email'], 'cerebrate' => $user['individual']['email']]; + } + if ($cRolename) { + $differences['role_name'] = ['kc' => $keycloakUser['attributes']['role_name'], 'cerebrate' => $user['role']['name']]; + } + if ($cRoleuuid) { + $differences['role_uuid'] = ['kc' => $keycloakUser['attributes']['role_uuid'], 'cerebrate' => $user['role']['uuid']]; + } + if ($cOrgname) { + $differences['org_name'] = ['kc' => $keycloakUser['attributes']['org_name'], 'cerebrate' => $user['organisation']['name']]; + } + if ($cOrguuid) { + $differences['org_uuid'] = ['kc' => $keycloakUser['attributes']['org_uuid'], 'cerebrate' => $user['organisation']['uuid']]; + } + return true; + } + return false; + } + private function createUser(array $user, string $clientId) { $newUser = [ diff --git a/src/Model/Behavior/NotifyAdminsBehavior.php b/src/Model/Behavior/NotifyAdminsBehavior.php new file mode 100644 index 0000000..3bf9993 --- /dev/null +++ b/src/Model/Behavior/NotifyAdminsBehavior.php @@ -0,0 +1,332 @@ + [ + '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); + } +} diff --git a/src/Model/Entity/AppModel.php b/src/Model/Entity/AppModel.php index 8dcd07f..3e70518 100644 --- a/src/Model/Entity/AppModel.php +++ b/src/Model/Entity/AppModel.php @@ -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 { } diff --git a/src/Model/Entity/Inbox.php b/src/Model/Entity/Inbox.php index 820e29d..21a9b68 100644 --- a/src/Model/Entity/Inbox.php +++ b/src/Model/Entity/Inbox.php @@ -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]; + } } diff --git a/src/Model/Entity/MetaTemplateField.php b/src/Model/Entity/MetaTemplateField.php index 7baf30a..958c3de 100644 --- a/src/Model/Entity/MetaTemplateField.php +++ b/src/Model/Entity/MetaTemplateField.php @@ -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; + } } diff --git a/src/Model/Entity/Outbox.php b/src/Model/Entity/Outbox.php index 51304e6..98cb40a 100644 --- a/src/Model/Entity/Outbox.php +++ b/src/Model/Entity/Outbox.php @@ -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]; + } } diff --git a/src/Model/Table/AppTable.php b/src/Model/Table/AppTable.php index 70ea76f..531714c 100644 --- a/src/Model/Table/AppTable.php +++ b/src/Model/Table/AppTable.php @@ -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 diff --git a/src/Model/Table/InboxTable.php b/src/Model/Table/InboxTable.php index 0617cea..a7626e7 100644 --- a/src/Model/Table/InboxTable.php +++ b/src/Model/Table/InboxTable.php @@ -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; } } diff --git a/src/Model/Table/MetaTemplateFieldsTable.php b/src/Model/Table/MetaTemplateFieldsTable.php index 626a96c..857361c 100644 --- a/src/Model/Table/MetaTemplateFieldsTable.php +++ b/src/Model/Table/MetaTemplateFieldsTable.php @@ -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(); } diff --git a/src/Model/Table/OrganisationsTable.php b/src/Model/Table/OrganisationsTable.php index 723b46a..f56f36b 100644 --- a/src/Model/Table/OrganisationsTable.php +++ b/src/Model/Table/OrganisationsTable.php @@ -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', [ diff --git a/src/Model/Table/OutboxTable.php b/src/Model/Table/OutboxTable.php index a78e0c6..eaf7358 100644 --- a/src/Model/Table/OutboxTable.php +++ b/src/Model/Table/OutboxTable.php @@ -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 diff --git a/src/Model/Table/PermissionLimitationsTable.php b/src/Model/Table/PermissionLimitationsTable.php index 1b5f193..abb8543 100644 --- a/src/Model/Table/PermissionLimitationsTable.php +++ b/src/Model/Table/PermissionLimitationsTable.php @@ -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', [ diff --git a/src/Model/Table/UsersTable.php b/src/Model/Table/UsersTable.php index a2ebfeb..8f74390 100644 --- a/src/Model/Table/UsersTable.php +++ b/src/Model/Table/UsersTable.php @@ -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; } diff --git a/src/Utility/UI/Notification.php b/src/Utility/UI/Notification.php index a6bf05b..b7e3f17 100644 --- a/src/Utility/UI/Notification.php +++ b/src/Utility/UI/Notification.php @@ -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(); diff --git a/src/View/Helper/BootstrapHelper.php b/src/View/Helper/BootstrapHelper.php index f8d5024..5b487c6 100644 --- a/src/View/Helper/BootstrapHelper.php +++ b/src/View/Helper/BootstrapHelper.php @@ -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); } diff --git a/templates/AuditLogs/index.php b/templates/AuditLogs/index.php index e46892c..52526f6 100644 --- a/templates/AuditLogs/index.php +++ b/templates/AuditLogs/index.php @@ -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', diff --git a/templates/Inbox/index.php b/templates/Inbox/index.php index 6576ec3..bee8244 100644 --- a/templates/Inbox/index.php +++ b/templates/Inbox/index.php @@ -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', [ ?> + + \ No newline at end of file diff --git a/templates/element/genericElements/Form/fieldScaffold.php b/templates/element/genericElements/Form/fieldScaffold.php index 32b339b..18e6000 100644 --- a/templates/element/genericElements/Form/fieldScaffold.php +++ b/templates/element/genericElements/Form/fieldScaffold.php @@ -1,11 +1,10 @@ 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; \ No newline at end of file +$fieldContainer = $this->Bootstrap->genNode('div', [ + 'class' => [], +], $fieldsHtml); +echo $fieldContainer; \ No newline at end of file diff --git a/templates/element/genericElements/Form/multiFieldButton.php b/templates/element/genericElements/Form/multiFieldButton.php index c7c778b..ddc17ca 100644 --- a/templates/element/genericElements/Form/multiFieldButton.php +++ b/templates/element/genericElements/Form/multiFieldButton.php @@ -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') + }) } })() \ No newline at end of file diff --git a/templates/element/genericElements/Form/multiFieldScaffold.php b/templates/element/genericElements/Form/multiFieldScaffold.php index 3a73125..3099813 100644 --- a/templates/element/genericElements/Form/multiFieldScaffold.php +++ b/templates/element/genericElements/Form/multiFieldScaffold.php @@ -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, ] ); diff --git a/templates/element/genericElements/IndexTable/Fields/keycloak_status.php b/templates/element/genericElements/IndexTable/Fields/keycloak_status.php new file mode 100644 index 0000000..bc2b95b --- /dev/null +++ b/templates/element/genericElements/IndexTable/Fields/keycloak_status.php @@ -0,0 +1,14 @@ +Hash->get($row, $field['data_path']); + if (is_null($data)) { + echo ''; + } else if (!empty($data['require_update'])) { + echo sprintf( + '%s', + 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',]); + } +?> diff --git a/templates/element/genericElements/IndexTable/index_table.php b/templates/element/genericElements/IndexTable/index_table.php index dc7453c..956fcf8 100644 --- a/templates/element/genericElements/IndexTable/index_table.php +++ b/templates/element/genericElements/IndexTable/index_table.php @@ -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, ]; diff --git a/templates/element/genericElements/ListTopBar/group_context_filters.php b/templates/element/genericElements/ListTopBar/group_context_filters.php index e441515..71b97e6 100644 --- a/templates/element/genericElements/ListTopBar/group_context_filters.php +++ b/templates/element/genericElements/ListTopBar/group_context_filters.php @@ -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 = [ diff --git a/templates/element/genericElements/ListTopBar/group_table_action.php b/templates/element/genericElements/ListTopBar/group_table_action.php index fdb799c..fe538b2 100644 --- a/templates/element/genericElements/ListTopBar/group_table_action.php +++ b/templates/element/genericElements/ListTopBar/group_table_action.php @@ -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, ]); ?> @@ -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, ] ] ]); ?> + + \ No newline at end of file diff --git a/templates/element/genericElements/ListTopBar/group_table_action/compactDisplay.php b/templates/element/genericElements/ListTopBar/group_table_action/compactDisplay.php index 312ad37..28eaa4d 100644 --- a/templates/element/genericElements/ListTopBar/group_table_action/compactDisplay.php +++ b/templates/element/genericElements/ListTopBar/group_table_action/compactDisplay.php @@ -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) }) })() \ No newline at end of file diff --git a/templates/element/genericElements/ListTopBar/group_table_action/hiddenColumns.php b/templates/element/genericElements/ListTopBar/group_table_action/hiddenColumns.php index 3a95b82..8e14c6d 100644 --- a/templates/element/genericElements/ListTopBar/group_table_action/hiddenColumns.php +++ b/templates/element/genericElements/ListTopBar/group_table_action/hiddenColumns.php @@ -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: $('
').append( - $('').text(''), - $('
', + $this->Bootstrap->icon('exclamation-triangle') . __(' This user is not synchronise with Keycloak. Differences:'), + $this->Html->nestedList($differencesRearranged, ['class' => '']) + ); + } else { + echo $this->Bootstrap->icon('check', ['class' => 'text-success',]); + } +?> diff --git a/templates/element/layouts/header/header-notification-item.php b/templates/element/layouts/header/header-notification-item.php index 59a3df8..4457e68 100644 --- a/templates/element/layouts/header/header-notification-item.php +++ b/templates/element/layouts/header/header-notification-item.php @@ -21,7 +21,7 @@ $variant = empty($notification['variant']) ? 'primary' : $notification['variant' -
+
ValueGetter->get($notification['text']) ?> format('Y-m-d\TH:i:s')) ?> diff --git a/templates/genericTemplates/filters.php b/templates/genericTemplates/filters.php index 33fb379..a193721 100644 --- a/templates/genericTemplates/filters.php +++ b/templates/genericTemplates/filters.php @@ -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('', '=', '='), sprintf('', '!=', '!='), ]; + if ($type === 'datetime') { + $options = [ + sprintf('', '>=', '>='), + sprintf('', '<=', '<='), + ]; + } return sprintf('', implode('', $options)); } ], @@ -38,8 +47,21 @@ $filteringForm = $this->Bootstrap->table( __('Value'), sprintf('', __('Supports strict matches and LIKE matches with the `%` character. Example: `%.com`')) ), - 'formatter' => function ($field, $row) { - return sprintf(''); + 'formatter' => function ($field, $row) use ($typeMap, $formTypeMap) { + $fieldName = $row['fieldname']; + $formType = $formTypeMap[$typeMap[$fieldName]] ?? 'text'; + $this->Form->setTemplates([ + 'formGroup' => '
{{input}}
', + ]); + 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 } diff --git a/templates/layout/default.php b/templates/layout/default.php index 1509aea..e810136 100644 --- a/templates/layout/default.php +++ b/templates/layout/default.php @@ -88,7 +88,7 @@ $sidebarOpen = $loggedUser->user_settings_by_name_with_fallback['ui.sidebar.expa
-
+
diff --git a/webroot/css/layout.css b/webroot/css/layout.css index 9762b1b..1afecec 100644 --- a/webroot/css/layout.css +++ b/webroot/css/layout.css @@ -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; diff --git a/webroot/js/api-helper.js b/webroot/js/api-helper.js index 9062c23..24d34cc 100644 --- a/webroot/js/api-helper.js +++ b/webroot/js/api-helper.js @@ -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); diff --git a/webroot/js/bootstrap-helper.js b/webroot/js/bootstrap-helper.js index 1d5a2ae..173fdca 100644 --- a/webroot/js/bootstrap-helper.js +++ b/webroot/js/bootstrap-helper.js @@ -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 = $('
') $toastHeader.addClass('toast-' + options.variant) - if (options.title !== false || options.titleHtml !== false) { - var $toastHeaderText - if (options.titleHtml !== false) { - $toastHeaderText = $('
').html(options.titleHtml); - } else { - $toastHeaderText = $('').text(options.title) - } - $toastHeader.append($toastHeaderText) + let $toastHeaderText = $('') + if (options.titleHtml !== false) { + $toastHeaderText = $('
').html(options.titleHtml); + } else if (options.title !== false) { + $toastHeaderText = $('').text(options.title) } + $toastHeader.append($toastHeaderText) if (options.muted !== false || options.mutedHtml !== false) { var $toastHeaderMuted if (options.mutedHtml !== false) { diff --git a/webroot/js/settings.js b/webroot/js/settings.js index 622f9a0..e91ef81 100644 --- a/webroot/js/settings.js +++ b/webroot/js/settings.js @@ -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) { diff --git a/webroot/js/table-settings.js b/webroot/js/table-settings.js index 2d1e3ea..906a3c4 100644 --- a/webroot/js/table-settings.js +++ b/webroot/js/table-settings.js @@ -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: $('
').append( + $('').text('The table needs to be reloaded for the new fields to be included.'), + $('