From a3c23f46d56ecd2937745e0ecfdb0707e57c6f5b Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Tue, 5 Sep 2023 10:33:08 +0200 Subject: [PATCH 01/17] fix: [mailinglist:ACL] Fixed bug in ACL check for access --- src/Controller/MailingListsController.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Controller/MailingListsController.php b/src/Controller/MailingListsController.php index e8e6a7c..43a18cc 100644 --- a/src/Controller/MailingListsController.php +++ b/src/Controller/MailingListsController.php @@ -29,7 +29,7 @@ class MailingListsController extends AppController 'quickFilters' => $this->quickFilterFields, 'statisticsFields' => $this->statisticsFields, 'afterFind' => function ($row) use ($currentUser) { - if (empty($currentUser['role']['perm_admin']) || $row['user_id'] != $currentUser['id']) { + if (empty($currentUser['role']['perm_admin']) && $row['user_id'] != $currentUser['id']) { if (!$this->MailingLists->isIndividualListed($currentUser['individual_id'], $row)) { $row = false; } @@ -66,7 +66,7 @@ class MailingListsController extends AppController $this->CRUD->view($id, [ 'contain' => $this->containFields, 'afterFind' => function($data) use ($currentUser) { - if (empty($currentUser['role']['perm_admin']) || $data['user_id'] != $currentUser['id']) { + if (empty($currentUser['role']['perm_admin']) && $data['user_id'] != $currentUser['id']) { if (!$this->MailingLists->isIndividualListed($currentUser['individual_id'], $data)) { $data = []; } @@ -131,7 +131,7 @@ class MailingListsController extends AppController if (is_null($mailingList)) { throw new NotFoundException(__('Invalid {0}.', Inflector::singularize($this->MailingLists->getAlias()))); } - if (empty($currentUser['role']['perm_admin']) || $mailingList['user_id'] != $currentUser['id']) { + if (empty($currentUser['role']['perm_admin']) && $mailingList['user_id'] != $currentUser['id']) { if (!$this->MailingLists->isIndividualListed($currentUser['individual_id'], $mailingList)) { throw new NotFoundException(__('Invalid {0}.', Inflector::singularize($this->MailingLists->getAlias()))); } From 883f0eb44ab7afc0d49f2ae00730ec0bb5b384cc Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Tue, 5 Sep 2023 10:46:25 +0200 Subject: [PATCH 02/17] fix: [userSettings:add] Aded check to avoid duplicated setting for the same user --- src/Controller/UserSettingsController.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Controller/UserSettingsController.php b/src/Controller/UserSettingsController.php index 6bcd197..29185a6 100644 --- a/src/Controller/UserSettingsController.php +++ b/src/Controller/UserSettingsController.php @@ -70,6 +70,12 @@ class UserSettingsController extends AppController $this->CRUD->add([ 'redirect' => ['action' => 'index', $user_id], 'beforeSave' => function ($data) use ($currentUser) { + $fakeUser = new \stdClass(); + $fakeUser->id = $data['user_id']; + $existingSetting = $this->UserSettings->getSettingByName($fakeUser, $data['name']); + if (!empty($existingSetting)) { + throw new MethodNotAllowedException(__('You cannot create a setting that already exists for the given user.')); + } if (empty($currentUser['role']['perm_admin'])) { $data['user_id'] = $currentUser->id; } From 9e18f4f0bbe36d5055d21b87d95cb182b4a11dca Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Tue, 5 Sep 2023 10:49:23 +0200 Subject: [PATCH 03/17] chg: [ACL:individual/add] Allow `org-admin`s to create new individuals --- src/Controller/Component/ACLComponent.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Controller/Component/ACLComponent.php b/src/Controller/Component/ACLComponent.php index 7e8cd2f..0d6fff6 100644 --- a/src/Controller/Component/ACLComponent.php +++ b/src/Controller/Component/ACLComponent.php @@ -96,7 +96,7 @@ class ACLComponent extends Component 'view' => ['perm_admin'], ], 'Individuals' => [ - 'add' => ['perm_admin'], + 'add' => ['perm_admin', 'perm_org_admin'], 'delete' => ['perm_admin'], 'edit' => ['perm_admin', 'perm_org_admin'], 'filtering' => ['*'], From e7e5c0aebd1f712bcffb18c95efbf146e0b93626 Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Tue, 5 Sep 2023 10:56:48 +0200 Subject: [PATCH 04/17] chg: [ACL:tags] Relaxed ACL on tags for index and view pages. FIXME: Adapt changes in the UI to hide add/edit buttons and add link to the sidebar --- src/Controller/Component/ACLComponent.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Controller/Component/ACLComponent.php b/src/Controller/Component/ACLComponent.php index 0d6fff6..d403c28 100644 --- a/src/Controller/Component/ACLComponent.php +++ b/src/Controller/Component/ACLComponent.php @@ -208,6 +208,13 @@ class ACLComponent extends Component 'removeOrg' => ['perm_org_admin'], 'view' => ['*'] ], + 'Tags' => [ + 'add' => ['perm_admin'], + 'delete' => ['perm_admin'], + 'edit' => ['perm_admin'], + 'index' => ['*'], + 'view' => ['*'] + ], 'Users' => [ 'add' => ['perm_org_admin'], 'delete' => ['perm_org_admin'], From 3514f8bd7cbbf675cb703918d14708b5ec4271e5 Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Wed, 6 Sep 2023 09:18:40 +0200 Subject: [PATCH 05/17] chg: [genericElements:numberOfElement] Added parameter to show or not the `show all` option --- templates/Users/index.php | 2 +- .../element/genericElements/ListTopBar/group_table_action.php | 1 + .../ListTopBar/group_table_action/numberOfElement.php | 4 +++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/templates/Users/index.php b/templates/Users/index.php index d06578c..6fae052 100644 --- a/templates/Users/index.php +++ b/templates/Users/index.php @@ -108,7 +108,7 @@ echo $this->element('genericElements/IndexTable/index_table', [ ], '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.'), - 'pull' => 'right', + 'includeAllPagination' => true, 'actions' => [ [ 'url' => '/users/view', diff --git a/templates/element/genericElements/ListTopBar/group_table_action.php b/templates/element/genericElements/ListTopBar/group_table_action.php index ef14480..7a4df9b 100644 --- a/templates/element/genericElements/ListTopBar/group_table_action.php +++ b/templates/element/genericElements/ListTopBar/group_table_action.php @@ -63,6 +63,7 @@ $numberOfElementHtml = $this->element('/genericElements/ListTopBar/group_table_a 'tableSettings' => $tableSettings, 'table_setting_id' => $data['table_setting_id'], 'numberOfElement' => $numberOfElement, + 'includeAll' => !empty($table_data['includeAllPagination']), ]); ?> diff --git a/templates/element/genericElements/ListTopBar/group_table_action/numberOfElement.php b/templates/element/genericElements/ListTopBar/group_table_action/numberOfElement.php index e3913d1..37104bd 100644 --- a/templates/element/genericElements/ListTopBar/group_table_action/numberOfElement.php +++ b/templates/element/genericElements/ListTopBar/group_table_action/numberOfElement.php @@ -10,7 +10,9 @@ $numberOfElementSelectSeed = 'seed-' . mt_rand(); - + + + From 7377e77204c45043740d2a537bbb7a51fbe39afd Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Wed, 6 Sep 2023 09:48:16 +0200 Subject: [PATCH 06/17] chg: [navigation:individuals] Only show edit and deletion buttons if users are allowed to do it --- .../Component/Navigation/Individuals.php | 17 +++++++++++++++++ .../Component/NavigationComponent.php | 14 ++++++++++++++ src/Controller/IndividualsController.php | 16 ++++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/src/Controller/Component/Navigation/Individuals.php b/src/Controller/Component/Navigation/Individuals.php index e592e13..f2ed784 100644 --- a/src/Controller/Component/Navigation/Individuals.php +++ b/src/Controller/Component/Navigation/Individuals.php @@ -5,4 +5,21 @@ require_once(APP . 'Controller' . DS . 'Component' . DS . 'Navigation' . DS . 'b class IndividualsNavigation extends BaseNavigation { + public function addLinks() + { + $controller = 'Individuals'; + if (empty($this->viewVars['canEdit'])) { + $this->bcf->removeLink($controller, 'view', $controller, 'edit'); + $this->bcf->removeLink($controller, 'edit', $controller, 'edit'); + } + } + + public function addActions() + { + $controller = 'Individuals'; + if (empty($this->viewVars['canDelete'])) { + $this->bcf->removeAction($controller, 'view', $controller, 'delete'); + $this->bcf->removeAction($controller, 'edit', $controller, 'delete'); + } + } } diff --git a/src/Controller/Component/NavigationComponent.php b/src/Controller/Component/NavigationComponent.php index 452d575..301c308 100644 --- a/src/Controller/Component/NavigationComponent.php +++ b/src/Controller/Component/NavigationComponent.php @@ -402,6 +402,20 @@ class BreadcrumbFactory } } + public function removeAction(string $sourceController, string $sourceAction, string $targetController, string $targetAction) + { + $routeSourceConfig = $this->getRouteConfig($sourceController, $sourceAction, true); + if (!empty($routeSourceConfig['actions'])) { + foreach ($routeSourceConfig['actions'] as $i => $routeConfig) { + if ($routeConfig['controller'] == $targetController && $routeConfig['action'] == $targetAction) { + unset($routeSourceConfig['actions'][$i]); + $this->endpoints[$sourceController][$sourceAction]['actions'] = $routeSourceConfig['actions']; + break; + } + } + } + } + public function getRouteConfig($controller, $action, $fullRoute = false) { $routeConfig = $this->get($controller, $action); diff --git a/src/Controller/IndividualsController.php b/src/Controller/IndividualsController.php index 46e52e8..8702891 100644 --- a/src/Controller/IndividualsController.php +++ b/src/Controller/IndividualsController.php @@ -69,6 +69,7 @@ class IndividualsController extends AppController return $responsePayload; } $this->set('canEdit', $this->canEdit($id)); + $this->set('canDelete', $this->canDelete($id)); } public function edit($id) @@ -89,6 +90,8 @@ class IndividualsController extends AppController if (!empty($responsePayload)) { return $responsePayload; } + $this->set('canEdit', $this->canEdit($id)); + $this->set('canDelete', $this->canDelete($id)); $this->render('add'); } @@ -155,4 +158,17 @@ class IndividualsController extends AppController } return false; } + + private function canDelete($indId): bool + { + $associatedUsersCount = $this->Individuals->Users->find()->select(['id'])->where(['individual_id' => $indId])->count(); + if ($associatedUsersCount > 0) { + return false; + } + $currentUser = $this->ACL->getUser(); + if ($currentUser['role']['perm_admin']) { + return true; + } + return false; + } } From bde01882d9468106de1e0a967fd9c8cb009b2dfb Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Wed, 6 Sep 2023 10:17:06 +0200 Subject: [PATCH 07/17] fix: [navigation:CRUDAction-auditlogs] Make ordering by created field unambigous and hide audit button to non-admin users --- .../Component/NavigationComponent.php | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/Controller/Component/NavigationComponent.php b/src/Controller/Component/NavigationComponent.php index 301c308..6eaa9ff 100644 --- a/src/Controller/Component/NavigationComponent.php +++ b/src/Controller/Component/NavigationComponent.php @@ -125,7 +125,7 @@ class NavigationComponent extends Component public function genBreadcrumb(): array { $request = $this->request; - $bcf = new BreadcrumbFactory($this->iconToTableMapping); + $bcf = new BreadcrumbFactory($this->iconToTableMapping, $this->getController()); $fullConfig = $this->getFullConfig($bcf, $this->request); return $fullConfig; } @@ -191,9 +191,10 @@ class BreadcrumbFactory private $endpoints = []; private $iconToTableMapping = []; - public function __construct($iconToTableMapping) + public function __construct($iconToTableMapping, $controllerContext) { $this->iconToTableMapping = $iconToTableMapping; + $this->controllerContext = $controllerContext; } public function defaultCRUD(string $controller, string $action, array $overrides = []): array @@ -243,7 +244,7 @@ class BreadcrumbFactory $item = $this->genRouteConfig($controller, $action, [ 'label' => __('Audit changes'), 'icon' => 'history', - 'url' => "/audit-logs?model={{model}}&model_id={{id}}&sort=created&direction=desc&embedInModal=1&excludeStats=1&skipTableToolbar=1", + 'url' => "/audit-logs?model={{model}}&model_id={{id}}&sort=AuditLogs.created&direction=desc&embedInModal=1&excludeStats=1&skipTableToolbar=1", 'url_vars' => ['id' => 'id', 'model' => ['raw' => $table->getAlias()]], 'textGetter' => !empty($table->getDisplayField()) ? $table->getDisplayField() : 'id', ]); @@ -288,6 +289,8 @@ class BreadcrumbFactory public function setDefaultCRUDForModel($controller) { + $loggedUser = $this->controllerContext->ACL->getUser(); + $this->addRoute($controller, 'index', $this->defaultCRUD($controller, 'index')); $this->addRoute($controller, 'view', $this->defaultCRUD($controller, 'view')); $this->addRoute($controller, 'add', $this->defaultCRUD($controller, 'add')); @@ -307,10 +310,14 @@ class BreadcrumbFactory $this->addAction($controller, 'view', $controller, 'add'); $this->addAction($controller, 'view', $controller, 'delete'); - $this->addAction($controller, 'view', $controller, 'audit'); + if (!empty($loggedUser['role']['perm_admin'])) { + $this->addAction($controller, 'view', $controller, 'audit'); + } $this->addAction($controller, 'edit', $controller, 'add'); $this->addAction($controller, 'edit', $controller, 'delete'); - $this->addAction($controller, 'edit', $controller, 'audit'); + if (!empty($loggedUser['role']['perm_admin'])) { + $this->addAction($controller, 'edit', $controller, 'audit'); + } } public function get($controller, $action) From 01356824a2d0c10714babcf237a04161ac3c42dd Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Wed, 6 Sep 2023 10:42:08 +0200 Subject: [PATCH 08/17] chg: [navigation:tags] Updated UI to reflect users' permissions --- plugins/Tags/templates/Tags/index.php | 9 ++++++--- src/Controller/Component/Navigation/Tags.php | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/plugins/Tags/templates/Tags/index.php b/plugins/Tags/templates/Tags/index.php index 5f2fded..345809b 100644 --- a/plugins/Tags/templates/Tags/index.php +++ b/plugins/Tags/templates/Tags/index.php @@ -10,7 +10,8 @@ echo $this->element('genericElements/IndexTable/index_table', [ 'data' => [ 'type' => 'simple', 'text' => __('Add tag'), - 'popover_url' => '/tags/add' + 'popover_url' => '/tags/add', + 'requirement' => !empty($loggedUser['role']['perm_admin']), ] ] ], @@ -65,12 +66,14 @@ echo $this->element('genericElements/IndexTable/index_table', [ [ 'open_modal' => '/tags/edit/[onclick_params_data_path]', 'modal_params_data_path' => 'id', - 'icon' => 'edit' + 'icon' => 'edit', + 'requirement' => !empty($loggedUser['role']['perm_admin']), ], [ 'open_modal' => '/tags/delete/[onclick_params_data_path]', 'modal_params_data_path' => 'id', - 'icon' => 'trash' + 'icon' => 'trash', + 'requirement' => !empty($loggedUser['role']['perm_admin']), ], ] ] diff --git a/src/Controller/Component/Navigation/Tags.php b/src/Controller/Component/Navigation/Tags.php index 74c5735..0af9849 100644 --- a/src/Controller/Component/Navigation/Tags.php +++ b/src/Controller/Component/Navigation/Tags.php @@ -5,4 +5,21 @@ require_once(APP . 'Controller' . DS . 'Component' . DS . 'Navigation' . DS . 'b class TagsNavigation extends BaseNavigation { + public function addLinks() + { + $controller = 'Tags'; + if (empty($this->viewVars['loggedUser']['role']['perm_admin'])) { + $this->bcf->removeLink($controller, 'view', $controller, 'edit'); + $this->bcf->removeLink($controller, 'edit', $controller, 'edit'); + } + } + + public function addActions() + { + $controller = 'Tags'; + if (empty($this->viewVars['loggedUser']['role']['perm_admin'])) { + $this->bcf->removeAction($controller, 'view', $controller, 'delete'); + $this->bcf->removeAction($controller, 'edit', $controller, 'delete'); + } + } } From d46143a37f9ea94dad2414d626afc64e4c3d6be1 Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Thu, 7 Sep 2023 15:01:13 +0200 Subject: [PATCH 09/17] chg: [command:summary] Added data about the modified entity --- src/Command/SummaryCommand.php | 62 ++++++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/src/Command/SummaryCommand.php b/src/Command/SummaryCommand.php index f7622a1..3ae70de 100644 --- a/src/Command/SummaryCommand.php +++ b/src/Command/SummaryCommand.php @@ -65,7 +65,8 @@ class SummaryCommand extends Command $folderPath = rtrim($folderPath, '/'); $filename = sprintf('%s/%s.txt', $folderPath, $nationality); $file_input = fopen($filename, 'w'); - $organisationIDsForNationality = $this->_fetchOrganisationsForNationality($nationality); + $organisationForNationality = $this->_fetchOrganisationsForNationality($nationality); + $organisationIDsForNationality = array_keys($organisationForNationality); if (empty($organisationIDsForNationality)) { $message = sprintf('No changes for organisations with nationality `%s`', $nationality); fwrite($file_input, $message); @@ -73,6 +74,7 @@ class SummaryCommand extends Command return; } $userForOrg = $this->_fetchUserForOrg($organisationIDsForNationality); + $userEmailByID = Hash::combine($userForOrg, '{n}.id', '{n}.individual.email'); $userID = Hash::extract($userForOrg, '{n}.id'); $individualID = Hash::extract($userForOrg, '{n}.individual_id'); @@ -80,9 +82,16 @@ class SummaryCommand extends Command fwrite($file_input, $message); $this->io->out($message); $logsUsers = $this->_fetchLogsForUsers($userID, $days); + $logsUsers = array_map(function($log) use ($userEmailByID) { + $userID = $log['model_id']; + $log['element_id'] = $userID; + $log['element_display_field'] = $userEmailByID[$userID]; + return $log; + }, $logsUsers); + $userByIDs = Hash::combine($userForOrg, '{n}.id', '{n}'); $logsUserMetaFields = $this->_fetchLogsForUserMetaFields($userID, $days); - $logsUserMetaFields = $this->_formatUserMetafieldLogs($logsUserMetaFields, $userByIDs); + $logsUserMetaFields = $this->_formatUserMetafieldLogs($logsUserMetaFields, $userEmailByID); $logsUsersCombined = array_merge($logsUsers, $logsUserMetaFields); usort($logsUsersCombined, function($a, $b) { return $a['created'] < $b['created'] ? -1 : 1; @@ -97,6 +106,12 @@ class SummaryCommand extends Command fwrite($file_input, $message); $this->io->out($message); $logsOrgs = $this->_fetchLogsForOrgs($organisationIDsForNationality, $days); + $logsOrgs = array_map(function ($log) use ($organisationIDsForNationality) { + $orgID = $log['model_id']; + $log['element_id'] = $orgID; + $log['element_display_field'] = $organisationIDsForNationality[$orgID]; + return $log; + }, $logsOrgs); $modifiedOrgs = $this->_formatLogsForTable($logsOrgs); foreach ($modifiedOrgs as $row) { fputcsv($file_input, $row); @@ -107,6 +122,12 @@ class SummaryCommand extends Command fwrite($file_input, $message); $this->io->out($message); $logsIndividuals = $this->_fetchLogsForIndividuals($individualID, $days); + $logsIndividuals = array_map(function ($log) use ($userEmailByID) { + $individualID = $log['model_id']; + $log['element_id'] = $individualID; + $log['element_display_field'] = $userEmailByID[$individualID]; + return $log; + }, $logsIndividuals); $modifiedIndividuals = $this->_formatLogsForTable($logsIndividuals); foreach ($modifiedIndividuals as $row) { fputcsv($file_input, $row); @@ -125,12 +146,18 @@ class SummaryCommand extends Command protected function _fetchOrganisationsForNationality(string $nationality): array { - return array_keys($this->Organisations->find('list') + return $this->Organisations->find('list') ->where([ 'nationality' => $nationality, ]) ->all() - ->toArray()); + ->toArray(); + // return array_keys($this->Organisations->find('list') + // ->where([ + // 'nationality' => $nationality, + // ]) + // ->all() + // ->toArray()); } protected function _fetchOrgNationalities(): array @@ -139,6 +166,7 @@ class SummaryCommand extends Command ->where([ 'nationality !=' => '', ]) + ->group('nationality') ->all() ->extract('nationality') ->toList(); @@ -190,9 +218,14 @@ class SummaryCommand extends Command $metaFieldLogs = array_filter($logs, function ($log) use ($userIDs) { return !empty($log['changed']['scope']) && $log['changed']['scope'] === 'user' && in_array($log['changed']['parent_id'], $userIDs); }); - $metaFieldDeletionLogs = array_filter($logs, function ($log) use ($userIDs) { + $metaFieldLogs = array_map(function ($log) { + $log['modified_user_id'] = $log['changed']['parent_id']; + return $log; + }, $metaFieldLogs); + $metaFieldDeletionLogs = array_filter($logs, function ($log) { return $log['request_action'] === 'delete'; }); + $allLogs = $metaFieldLogs; foreach ($metaFieldDeletionLogs as $i => $log) { $latestAssociatedLog = $this->_fetchLogs([ 'contain' => ['Users'], @@ -205,11 +238,14 @@ class SummaryCommand extends Command 'limit' => 1, ]); if (!empty($latestAssociatedLog)) { - $metaFieldDeletionLogs[$i]['changed']['orig_value'] = $latestAssociatedLog[0]['changed']['value']; - $metaFieldDeletionLogs[$i]['changed']['value'] = ''; + if (in_array($latestAssociatedLog[0]['changed']['parent_id'], $userIDs)) { + $log['changed']['orig_value'] = $latestAssociatedLog[0]['changed']['value']; + $log['changed']['value'] = ''; + $log['modified_user_id'] = $latestAssociatedLog[0]['changed']['parent_id']; + $allLogs[] = $log; + } } } - $allLogs = array_merge($metaFieldLogs, $metaFieldDeletionLogs); return $allLogs; } @@ -268,9 +304,9 @@ class SummaryCommand extends Command }, $logs); } - protected function _formatUserMetafieldLogs($logEntries, $userByIDs): array + protected function _formatUserMetafieldLogs($logEntries, $userEmailByID): array { - return array_map(function($log) use ($userByIDs) { + return array_map(function($log) use ($userEmailByID) { $log['model'] = 'Users'; $log['request_action'] = 'edit'; $log['changed'] = [ @@ -279,13 +315,15 @@ class SummaryCommand extends Command $log['changed']['value'] ] ]; + $log['element_id'] = $log['modified_user_id']; + $log['element_display_field'] = $userEmailByID[$log['modified_user_id']]; return $log; }, $logEntries); } protected function _formatLogsForTable($logEntries): array { - $header = ['Model', 'Action', 'Editor user', 'Log ID', 'Datetime', 'Change']; + $header = ['Model', 'Action', 'Editor user', 'Log ID', 'Datetime', 'Modified element ID', 'Modified element', 'Change']; $data = [$header]; foreach ($logEntries as $logEntry) { $formatted = [ @@ -294,6 +332,8 @@ class SummaryCommand extends Command sprintf('%s (%s)', $logEntry['user']['username'], $logEntry['user_id']), $logEntry['id'], $logEntry['created']->i18nFormat('yyyy-MM-dd HH:mm:ss'), + $logEntry['element_id'] ?? '-', + $logEntry['element_display_field'] ?? '-', ]; if ($logEntry['request_action'] == 'edit') { $formatted[] = json_encode($logEntry['changed'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); From 08d2e193dd8ed8cb94a35dacc66b4785ae25e00d Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Thu, 7 Sep 2023 15:14:26 +0200 Subject: [PATCH 10/17] chg: [user-settings:edit] Prevent assigning a setting to another user --- src/Controller/UserSettingsController.php | 12 ++++++++---- templates/UserSettings/add.php | 1 + 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Controller/UserSettingsController.php b/src/Controller/UserSettingsController.php index 29185a6..2a06985 100644 --- a/src/Controller/UserSettingsController.php +++ b/src/Controller/UserSettingsController.php @@ -118,13 +118,19 @@ class UserSettingsController extends AppController } else { $validUsers = $this->Users->find('list')->select(['id', 'username'])->order(['username' => 'asc'])->all()->toArray(); } + $dropdownData = [ + 'user' => [$entity->user_id => $validUsers[$entity->user_id]], + ]; $entity = $this->CRUD->edit($id, [ 'redirect' => ['action' => 'index', $entity->user_id], - 'beforeSave' => function ($data) use ($validUsers) { + 'beforeSave' => function ($data) use ($validUsers, $entity) { if (!in_array($data['user_id'], array_keys($validUsers))) { throw new MethodNotAllowedException(__('You cannot edit the given user.')); } + if ($data['user_id'] != $entity->user_id) { + throw new MethodNotAllowedException(__('You cannot assign the setting to a different user.')); + } return $data; } ]); @@ -132,11 +138,9 @@ class UserSettingsController extends AppController if (!empty($responsePayload)) { return $responsePayload; } - $dropdownData = [ - 'user' => $validUsers, - ]; $this->set(compact('dropdownData')); $this->set('user_id', $this->entity->user_id); + $this->set('is_edit', true); $this->render('add'); } diff --git a/templates/UserSettings/add.php b/templates/UserSettings/add.php index a691afc..99e29ec 100644 --- a/templates/UserSettings/add.php +++ b/templates/UserSettings/add.php @@ -10,6 +10,7 @@ 'label' => __('User'), 'options' => $dropdownData['user'], 'value' => !is_null($user_id) ? $user_id : '', + 'disabled' => !empty($is_edit), ], [ 'field' => 'name', From 5aefc3783746199f8259fa0e7d2d4b1ba5321def Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Thu, 7 Sep 2023 16:11:47 +0200 Subject: [PATCH 11/17] chg: [users:edit] Allow users to self edit --- src/Controller/Component/ACLComponent.php | 3 +++ src/Controller/UsersController.php | 9 ++++++--- templates/Users/index.php | 3 +++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/Controller/Component/ACLComponent.php b/src/Controller/Component/ACLComponent.php index d403c28..1689fc0 100644 --- a/src/Controller/Component/ACLComponent.php +++ b/src/Controller/Component/ACLComponent.php @@ -342,6 +342,9 @@ class ACLComponent extends Component if (!$currentUser['role']['perm_org_admin']) { return false; } else { + if ($currentUser['id'] == $user['id']) { + return true; + } if ($currentUser['organisation_id'] !== $user['organisation_id']) { return false; } diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index 574d344..698021c 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -51,7 +51,7 @@ class UsersController extends AppController } $this->set( 'validRoles', - $this->Users->Roles->find('list')->select(['id', 'name'])->order(['name' => 'asc'])->where(['perm_admin' => 0])->all()->toArray() + $this->Users->Roles->find('list')->select(['id', 'name'])->order(['name' => 'asc'])->where(['perm_admin' => 0, 'perm_org_admin' => 0])->all()->toArray() ); $this->set('metaGroup', $this->isAdmin ? 'Administration' : 'Cerebrate'); } @@ -259,7 +259,7 @@ class UsersController extends AppController $params['fields'][] = 'disabled'; if (!$currentUser['role']['perm_admin']) { $params['afterFind'] = function ($data, &$params) use ($currentUser, $validRoles) { - if (!in_array($data['role_id'], array_keys($validRoles))) { + if (!in_array($data['role_id'], array_keys($validRoles)) && $this->ACL->getUser()['id'] != $data['id']) { throw new MethodNotAllowedException(__('You cannot edit the given privileged user.')); } if (!$this->ACL->canEditUser($currentUser, $data)) { @@ -268,7 +268,7 @@ class UsersController extends AppController return $data; }; $params['beforeSave'] = function ($data) use ($currentUser, $validRoles) { - if (!in_array($data['role_id'], array_keys($validRoles))) { + if (!in_array($data['role_id'], array_keys($validRoles)) && $this->ACL->getUser()['id'] != $data['id']) { throw new MethodNotAllowedException(__('You cannot assign the chosen role to a user.')); } return $data; @@ -284,6 +284,9 @@ class UsersController extends AppController if (empty($currentUser['role']['perm_admin'])) { $org_conditions = ['id' => $currentUser['organisation_id']]; } + if ($this->ACL->getUser()['id'] == $id) { + $validRoles[$this->ACL->getUser()['role']['id']] = $this->ACL->getUser()['role']['name']; // include the current role of the user + } $dropdownData = [ 'role' => $validRoles, 'organisation' => $this->Users->Organisations->find('list', [ diff --git a/templates/Users/index.php b/templates/Users/index.php index 6fae052..f9388cb 100644 --- a/templates/Users/index.php +++ b/templates/Users/index.php @@ -127,6 +127,9 @@ echo $this->element('genericElements/IndexTable/index_table', [ ], 'function' => function ($row, $options) use ($loggedUser, $validRoles) { if (empty($loggedUser['role']['perm_admin'])) { + if ($row['id'] == $loggedUser['id']) { + return true; + } if (empty($loggedUser['role']['perm_org_admin'])) { return false; } From 367012af3670feea9536d8be9ec1766d03fc32fd Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Thu, 7 Sep 2023 16:43:30 +0200 Subject: [PATCH 12/17] fix: [individual:edit] Select individuals based on their id and not their user_id --- src/Model/Table/IndividualsTable.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Model/Table/IndividualsTable.php b/src/Model/Table/IndividualsTable.php index 277b2f7..aa13727 100644 --- a/src/Model/Table/IndividualsTable.php +++ b/src/Model/Table/IndividualsTable.php @@ -125,7 +125,7 @@ class IndividualsTable extends AppTable public function getValidIndividualsToEdit(object $currentUser): array { $validRoles = $this->Users->Roles->find('list')->select(['id'])->where(['perm_admin' => 0, 'perm_org_admin' => 0])->all()->toArray(); - $validIndividualIds = $this->Users->find('list')->select(['individual_id'])->where( + $validIndividualIds = $this->Users->find()->select(['individual_id'])->where( [ 'organisation_id' => $currentUser['organisation_id'], 'disabled' => 0, @@ -134,7 +134,7 @@ class IndividualsTable extends AppTable ['id' => $currentUser['id']], ] ] - )->all()->toArray(); - return array_keys($validIndividualIds); + )->all()->extract('individual_id')->toArray(); + return $validIndividualIds; } } From 8b4b47775ce8c68f19e37482abc0f8439f72544d Mon Sep 17 00:00:00 2001 From: Sami Mokaddem Date: Fri, 8 Sep 2023 09:11:52 +0200 Subject: [PATCH 13/17] chg: [alignments:acl] Reflected ACL logic from individuals to alignments --- src/Controller/AlignmentsController.php | 52 +++++++++- src/Controller/Component/ACLComponent.php | 4 +- src/Model/Table/OrganisationsTable.php | 13 +++ .../IndexTable/Fields/alignments.php | 77 ++++++++------- .../SingleViews/Fields/alignmentField.php | 96 +++++++++++-------- 5 files changed, 163 insertions(+), 79 deletions(-) diff --git a/src/Controller/AlignmentsController.php b/src/Controller/AlignmentsController.php index 6f48af5..c783148 100644 --- a/src/Controller/AlignmentsController.php +++ b/src/Controller/AlignmentsController.php @@ -49,6 +49,9 @@ class AlignmentsController extends AppController throw new NotFoundException(__('Invalid alignment.')); } $alignment = $this->Alignments->get($id); + if (!$this->canEditIndividual($alignment->individual_id) || !$this->canEditOrganisation($alignment->organisation_id)) { + throw new MethodNotAllowedException(__('You cannot delete this alignments.')); + } if ($this->request->is('post') || $this->request->is('delete')) { if ($this->Alignments->delete($alignment)) { $message = __('Alignments deleted.'); @@ -73,8 +76,21 @@ class AlignmentsController extends AppController if (empty($scope) || empty($source_id)) { throw new NotAcceptableException(__('Invalid input. scope and source_id expected as URL parameters in the format /alignments/add/[scope]/[source_id].')); } + if (!in_array($scope, ['individuals', 'organisations'])) { + throw new MethodNotAllowedException(__('Invalid scope. Should be `individuals` or `organisations`.')); + } $this->loadModel('Individuals'); $this->loadModel('Organisations'); + + $validIndividualIDs = $this->Individuals->getValidIndividualsToEdit($this->ACL->getUser()); + $validOrgs = $this->Organisations->getEditableOrganisationsForUser($this->ACL->getUser()); + + if ($scope == 'individuals' && !$this->canEditIndividual($source_id)) { + throw new MethodNotAllowedException(__('You cannot modify that individual.')); + } else if ($scope == 'organisations' && !$this->canEditOrganisation($source_id)) { + throw new MethodNotAllowedException(__('You cannot modify that organisation.')); + } + $alignment = $this->Alignments->newEmptyEntity(); if ($this->request->is('post')) { $this->Alignments->patchEntity($alignment, $this->request->getData()); @@ -83,6 +99,11 @@ class AlignmentsController extends AppController } else { $alignment['organisation_id'] = $source_id; } + if ($scope == 'individuals' && !$this->canEditOrganisation($alignment['organisation_id'])) { + throw new MethodNotAllowedException(__('You cannot use that organisation.')); + } else if ($scope == 'organisations' && !$this->canEditIndividual($alignment['individual_id'])) { + throw new MethodNotAllowedException(__('You cannot assign that individual.')); + } $alignment = $this->Alignments->save($alignment); if ($alignment) { $message = __('Alignment added.'); @@ -105,7 +126,7 @@ class AlignmentsController extends AppController } } if ($scope === 'organisations') { - $individuals = $this->Individuals->find('list', ['valueField' => 'email'])->toArray(); + $individuals = $this->Individuals->find('list', ['valueField' => 'email'])->where(['id IN' => $validIndividualIDs])->toArray(); $this->set('individuals', $individuals); $organisation = $this->Organisations->find()->where(['id' => $source_id])->first(); if (empty($organisation)) { @@ -113,7 +134,7 @@ class AlignmentsController extends AppController } $this->set(compact('organisation')); } else { - $organisations = $this->Organisations->find('list', ['valueField' => 'name'])->toArray(); + $organisations = Hash::combine($validOrgs, '{n}.id', '{n}.name'); $this->set('organisations', $organisations); $individual = $this->Individuals->find()->where(['id' => $source_id])->first(); if (empty($individual)) { @@ -124,6 +145,31 @@ class AlignmentsController extends AppController $this->set(compact('alignment')); $this->set('scope', $scope); $this->set('source_id', $source_id); - $this->set('metaGroup', 'ContactDB'); + } + + private function canEditIndividual($indId): bool + { + $currentUser = $this->ACL->getUser(); + if ($currentUser['role']['perm_admin']) { + return true; + } + $this->loadModel('Individuals'); + $validIndividuals = $this->Individuals->getValidIndividualsToEdit($currentUser); + if (in_array($indId, $validIndividuals)) { + return true; + } + return false; + } + + private function canEditOrganisation($orgId): bool + { + $currentUser = $this->ACL->getUser(); + if ($currentUser['role']['perm_admin']) { + return true; + } + if ($currentUser['role']['perm_org_admin'] && $currentUser['organisation']['id'] == $orgId) { + return true; + } + return false; } } diff --git a/src/Controller/Component/ACLComponent.php b/src/Controller/Component/ACLComponent.php index 1689fc0..90fefe4 100644 --- a/src/Controller/Component/ACLComponent.php +++ b/src/Controller/Component/ACLComponent.php @@ -41,8 +41,8 @@ class ACLComponent extends Component 'queryACL' => ['perm_admin'] ], 'Alignments' => [ - 'add' => ['perm_admin'], - 'delete' => ['perm_admin'], + 'add' => ['perm_admin', 'perm_org_admin'], + 'delete' => ['perm_admin', 'perm_org_admin'], 'index' => ['*'], 'view' => ['*'] ], diff --git a/src/Model/Table/OrganisationsTable.php b/src/Model/Table/OrganisationsTable.php index f56f36b..f74e53a 100644 --- a/src/Model/Table/OrganisationsTable.php +++ b/src/Model/Table/OrganisationsTable.php @@ -80,4 +80,17 @@ class OrganisationsTable extends AppTable $this->saveMetaFields($id, $org); } } + + public function getEditableOrganisationsForUser($user): array + { + $query = $this->find(); + if (empty($user['role']['perm_admin'])) { + if (!empty($user['role']['perm_org_admin'])) { + $query->where(['Organisations.id' => $user['organisation']['id']]); + } else { + return []; // User not an org_admin. Cannot edit orgs + } + } + return $query->all()->toList(); + } } diff --git a/templates/element/genericElements/IndexTable/Fields/alignments.php b/templates/element/genericElements/IndexTable/Fields/alignments.php index 15c29e1..b23d96e 100644 --- a/templates/element/genericElements/IndexTable/Fields/alignments.php +++ b/templates/element/genericElements/IndexTable/Fields/alignments.php @@ -4,43 +4,56 @@ $alignments = ''; $canRemove = $this->request->getParam('prefix') !== 'Open'; if ($field['scope'] === 'individuals') { foreach ($raw_alignments as $alignment) { - $alignments .= sprintf( - '
%s @ %s
', - h($alignment['type']), - sprintf( - '%s', - $baseurl, - h($alignment['organisation']['id']), - h($alignment['organisation']['name']) - ), - !$canRemove ? '' : sprintf( - "UI.submissionModalForIndex(%s);", - sprintf( - "'/alignments/delete/%s'", - h($alignment['id']) + $canEdit = in_array($alignment->individual_id, $editableIds); + $alignmentEntryHtml = $this->Bootstrap->node('span', ['class' => ['fw-bold']], h($alignment['type'])); + $alignmentEntryHtml .= ' @ ' . $this->Bootstrap->node('span', ['class' => ['ms-1']], sprintf( + '%s', + $baseurl, + h($alignment['organisation']['id']), + h($alignment['organisation']['name']) + ),); + if ($canRemove && !empty($canEdit)) { + $alignmentEntryHtml .= $this->Bootstrap->button([ + 'icon' => 'trash', + 'variant' => 'link', + 'class' => ['ms-1', 'p-0' + ], + 'onclick' => sprintf( + "UI.submissionModalForSinglePage(%s);", + sprintf( + "'/alignments/delete/%s'", + $alignment['id'] + ) ) - ) - ); + ]); + } + $alignments .= sprintf('
%s
', $alignmentEntryHtml); } } else if ($field['scope'] === 'organisations') { foreach ($raw_alignments as $alignment) { - $alignments .= sprintf( - '
[%s] %s
', - h($alignment['type']), - sprintf( - '%s', - $baseurl, - h($alignment['individual']['id']), - h($alignment['individual']['email']) - ), - !$canRemove ? '' : sprintf( - "UI.submissionModalForIndex(%s);", - sprintf( - "'/alignments/delete/%s'", - h($alignment['id']) + $canEdit = in_array($alignment->organisation_id, $editableIds); + $alignmentEntryHtml = '[' . $this->Bootstrap->node('span', ['class' => ['fw-bold']], h($alignment['type'])) . ']'; + $alignmentEntryHtml .= $this->Bootstrap->node('span', ['class' => ['ms-1']], sprintf( + '%s', + $baseurl, + h($alignment['individual']['id']), + h($alignment['individual']['email']) + ),); + if ($canRemove && !empty($canEdit)) { + $alignmentEntryHtml .= $this->Bootstrap->button([ + 'icon' => 'trash', + 'variant' => 'link', + 'class' => ['ms-1', 'p-0'], + 'onclick' => sprintf( + "UI.submissionModalForSinglePage(%s);", + sprintf( + "'/alignments/delete/%s'", + $alignment['id'] + ) ) - ) - ); + ]); + } + $alignments .= sprintf('
%s
', $alignmentEntryHtml); } } echo $alignments; diff --git a/templates/element/genericElements/SingleViews/Fields/alignmentField.php b/templates/element/genericElements/SingleViews/Fields/alignmentField.php index 1832ec0..48a78ab 100644 --- a/templates/element/genericElements/SingleViews/Fields/alignmentField.php +++ b/templates/element/genericElements/SingleViews/Fields/alignmentField.php @@ -10,52 +10,64 @@ if (!empty($field['path'])) { } if ($field['scope'] === 'individuals') { foreach ($extracted['alignments'] as $alignment) { - $alignments .= sprintf( - '
%s @ %s
', - h($alignment['type']), - sprintf( - '%s', - $baseurl, - h($alignment['organisation']['id']), - h($alignment['organisation']['name']) - ), - sprintf( - "UI.submissionModalForSinglePage(%s);", - sprintf( - "'/alignments/delete/%s'", - $alignment['id'] + $alignmentEntryHtml = $this->Bootstrap->node('span', ['class' => ['fw-bold']], h($alignment['type'])); + $alignmentEntryHtml .= ' @ ' . $this->Bootstrap->node('span', ['class' => ['ms-1']], sprintf( + '%s', + $baseurl, + h($alignment['organisation']['id']), + h($alignment['organisation']['name']) + ),); + if (!empty($canEdit)) { + $alignmentEntryHtml .= $this->Bootstrap->button([ + 'icon' => 'trash', + 'variant' => 'link', + 'class' => ['ms-1', 'p-0'], + 'onclick' => sprintf( + "UI.submissionModalForSinglePage(%s);", + sprintf( + "'/alignments/delete/%s'", + $alignment['id'] + ) ) - ) - ); + ]); + } + $alignments .= sprintf('
%s
', $alignmentEntryHtml); } } else if ($field['scope'] === 'organisations') { foreach ($extracted['alignments'] as $alignment) { - $alignments .= sprintf( - '
[%s] %s
', - h($alignment['type']), - sprintf( - '%s', - $baseurl, - h($alignment['individual']['id']), - h($alignment['individual']['email']) - ), - sprintf( - "UI.submissionModalForSinglePage(%s);", - sprintf( - "'/alignments/delete/%s'", - $alignment['id'] + $alignmentEntryHtml = '[' . $this->Bootstrap->node('span', ['class' => ['fw-bold']], h($alignment['type'])) . ']'; + $alignmentEntryHtml .= $this->Bootstrap->node('span', ['class' => ['ms-1']], sprintf( + '%s', + $baseurl, + h($alignment['individual']['id']), + h($alignment['individual']['email']) + ),); + if (!empty($canEdit)) { + $alignmentEntryHtml .= $this->Bootstrap->button([ + 'icon' => 'trash', + 'variant' => 'link', + 'class' => ['ms-1', 'p-0'], + 'onclick' => sprintf( + "UI.submissionModalForSinglePage(%s);", + sprintf( + "'/alignments/delete/%s'", + $alignment['id'] + ) ) - ) - ); + ]); + } + $alignments .= sprintf('
%s
', $alignmentEntryHtml); } } -echo sprintf( - '
%s
', - $alignments, - sprintf( - "UI.submissionModalForSinglePage('/alignments/add/%s/%s');", - h($field['scope']), - h($extracted['id']) - ), - $field['scope'] === 'individuals' ? __('Add organisation') : __('Add individual') -); +echo sprintf('
%s
', $alignments); +if (!empty($canEdit)) { + echo sprintf( + '
', + sprintf( + "UI.submissionModalForSinglePage('/alignments/add/%s/%s');", + h($field['scope']), + h($extracted['id']) + ), + $field['scope'] === 'individuals' ? __('Add organisation') : __('Add individual') + ); +} From 16a4a1cde5e41a45378055fd0b4191c703d65cc6 Mon Sep 17 00:00:00 2001 From: iglocska Date: Tue, 12 Sep 2023 09:26:07 +0200 Subject: [PATCH 14/17] new: [org Groups] added - Org Groups allow user co-management of sub communities - Create an org group with a set of organisations - assign administrators to an org group - org group admins can modify users of the group --- .../Migrations/20230907000003_OrgGrouping.php | 123 +++++++++ .../20230907000004_GroupAdminRole.php | 35 +++ src/Controller/Component/ACLComponent.php | 26 ++ src/Controller/Component/CRUDComponent.php | 128 +++++++++ .../Component/Navigation/sidemenu.php | 5 + .../Component/NavigationComponent.php | 1 + src/Controller/OrgGroupsController.php | 256 ++++++++++++++++++ src/Controller/OrganisationsController.php | 4 +- src/Controller/UsersController.php | 7 +- src/Model/Entity/OrgGroup.php | 26 ++ src/Model/Table/OrgGroupsTable.php | 48 ++++ src/Model/Table/OrganisationsTable.php | 3 + src/Model/Table/UsersTable.php | 3 + templates/OrgGroups/add.php | 25 ++ templates/OrgGroups/assign_admin.php | 20 ++ templates/OrgGroups/attach_org.php | 20 ++ templates/OrgGroups/index.php | 86 ++++++ templates/OrgGroups/list_admins.php | 64 +++++ templates/OrgGroups/list_orgs.php | 71 +++++ templates/OrgGroups/view.php | 44 +++ templates/Organisations/index.php | 8 + templates/Organisations/view.php | 103 ++++--- templates/Roles/add.php | 5 + templates/Roles/index.php | 6 + templates/Roles/view.php | 5 + templates/Users/index.php | 8 + templates/Users/view.php | 10 + .../IndexTable/Fields/link_list.php | 15 + .../ListTopBar/group_simple.php | 4 +- .../SingleViews/Fields/link_listField.php | 15 + templates/genericTemplates/detach.php | 37 +++ 31 files changed, 1161 insertions(+), 50 deletions(-) create mode 100644 config/Migrations/20230907000003_OrgGrouping.php create mode 100644 config/Migrations/20230907000004_GroupAdminRole.php create mode 100644 src/Controller/OrgGroupsController.php create mode 100644 src/Model/Entity/OrgGroup.php create mode 100644 src/Model/Table/OrgGroupsTable.php create mode 100644 templates/OrgGroups/add.php create mode 100644 templates/OrgGroups/assign_admin.php create mode 100644 templates/OrgGroups/attach_org.php create mode 100644 templates/OrgGroups/index.php create mode 100644 templates/OrgGroups/list_admins.php create mode 100644 templates/OrgGroups/list_orgs.php create mode 100644 templates/OrgGroups/view.php create mode 100644 templates/element/genericElements/IndexTable/Fields/link_list.php create mode 100644 templates/element/genericElements/SingleViews/Fields/link_listField.php create mode 100644 templates/genericTemplates/detach.php diff --git a/config/Migrations/20230907000003_OrgGrouping.php b/config/Migrations/20230907000003_OrgGrouping.php new file mode 100644 index 0000000..764bf39 --- /dev/null +++ b/config/Migrations/20230907000003_OrgGrouping.php @@ -0,0 +1,123 @@ +hasTable('org_groups'); + if (!$exists) { + $orgGroupsTable = $this->table('org_groups', [ + 'signed' => false, + 'collation' => 'utf8mb4_unicode_ci' + ]); + $orgGroupsTable + ->addColumn('id', 'integer', [ + 'autoIncrement' => true, + 'limit' => 10, + 'signed' => false, + ]) + ->addPrimaryKey('id') + ->addColumn('uuid', 'string', [ + 'null' => false, + 'limit' => 40, + 'collation' => 'ascii_general_ci', + 'encoding' => 'ascii', + ]) + ->addColumn('name', 'string', [ + 'null' => false, + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + ]) + ->addColumn('description', 'text', [ + 'default' => null, + 'null' => true + ]) + ->addColumn('created', 'datetime', [ + 'null' => false + ]) + ->addColumn('modified', 'datetime', [ + 'null' => false + ]) + ->addIndex('name') + ->addIndex('uuid', ['unique' => true]); + + $orgGroupsTable->create(); + } + + + $exists = $this->hasTable('org_groups_organisations'); + if (!$exists) { + $orgGroupsTable = $this->table('org_groups_organisations', [ + 'signed' => false, + 'collation' => 'utf8mb4_unicode_ci' + ]); + $orgGroupsTable + ->addColumn('id', 'integer', [ + 'autoIncrement' => true, + 'limit' => 10, + 'signed' => false, + ]) + ->addPrimaryKey('id') + ->addColumn('org_group_id', 'integer', [ + 'limit' => 10, + 'signed' => false, + ]) + ->addIndex('org_group_id') + ->addColumn('organisation_id', 'integer', [ + 'limit' => 10, + 'signed' => false, + ]) + ->addIndex('organisation_id') + ->addIndex(['org_group_id', 'organisation_id'], ['unique' => true]); + + $orgGroupsTable->create(); + } + + + $exists = $this->hasTable('org_groups_admins'); + if (!$exists) { + $orgGroupsTable = $this->table('org_groups_admins', [ + 'signed' => false, + 'collation' => 'utf8mb4_unicode_ci' + ]); + $orgGroupsTable + ->addColumn('id', 'integer', [ + 'autoIncrement' => true, + 'limit' => 10, + 'signed' => false, + ]) + ->addPrimaryKey('id') + ->addColumn('org_group_id', 'integer', [ + 'limit' => 10, + 'signed' => false, + ]) + ->addIndex('org_group_id') + ->addColumn('user_id', 'integer', [ + 'limit' => 10, + 'signed' => false, + ]) + ->addIndex('user_id') + ->addIndex(['org_group_id', 'user_id'], ['unique' => true]); + + $orgGroupsTable->create(); + } + } +} diff --git a/config/Migrations/20230907000004_GroupAdminRole.php b/config/Migrations/20230907000004_GroupAdminRole.php new file mode 100644 index 0000000..47b47bd --- /dev/null +++ b/config/Migrations/20230907000004_GroupAdminRole.php @@ -0,0 +1,35 @@ +table('roles')->hasColumn('perm_group_admin'); + if (!$exists) { + $this->table('roles') + ->addColumn('perm_group_admin', 'boolean', [ + 'default' => 0, + 'null' => false, + ]) + ->addIndex('perm_group_admin') + ->update(); + } + } +} diff --git a/src/Controller/Component/ACLComponent.php b/src/Controller/Component/ACLComponent.php index d403c28..dde8323 100644 --- a/src/Controller/Component/ACLComponent.php +++ b/src/Controller/Component/ACLComponent.php @@ -161,6 +161,23 @@ class ACLComponent extends Component 'MetaTemplateNameDirectory' => [ 'index' => ['perm_admin'], ], + 'OrgGroups' => [ + 'add' => ['perm_admin'], + 'delete' => ['perm_admin'], + 'edit' => ['perm_admin'], + 'index' => ['*'], + 'view' => ['*'], + 'filtering' => ['*'], + 'tag' => ['perm_admin'], + 'untag' => ['perm_admin'], + 'viewTags' => ['*'], + 'listAdmins' => ['*'], + 'listOrgs' => ['*'], + 'assignAdmin' => ['perm_admin'], + 'removeAdmin' => ['perm_admin'], + 'attachOrg' => ['perm_group_admin'], + 'detachOrg' => ['perm_group_admin'] + ], 'Organisations' => [ 'add' => ['perm_admin'], 'delete' => ['perm_admin'], @@ -335,10 +352,19 @@ class ACLComponent extends Component if (empty($user) || empty($currentUser)) { return false; } + if ($user['id'] === $currentUser['id']) { + return true; + } if (!$currentUser['role']['perm_admin']) { if ($user['role']['perm_admin']) { return false; // org_admins cannot edit admins } + if ($currentUser['role']['perm_group_admin']) { + $this->OrgGroup = TableRegistry::get('OrgGroup'); + if ($this->OrgGroup->checkIfUserBelongsToGroupAdminsGroup($currentUser, $user)) { + return true; + } + } if (!$currentUser['role']['perm_org_admin']) { return false; } else { diff --git a/src/Controller/Component/CRUDComponent.php b/src/Controller/Component/CRUDComponent.php index a1ecba1..47269ff 100644 --- a/src/Controller/Component/CRUDComponent.php +++ b/src/Controller/Component/CRUDComponent.php @@ -1711,4 +1711,132 @@ class CRUDComponent extends Component } return true; } + + public function linkObjects(string $functionName, int $id1, string $model1, string $model2, array $params = []): void + { + $this->Controller->loadModel($model1); + $this->Controller->loadModel($model2); + if ($this->request->is(['post', 'put'])) { + $input = $this->request->getData(); + if (empty($input['id'])) { + throw new MethodNotAllowedException(__('No ID of target to attach defined.')); + } + $id2 = $input['id']; + $obj1 = $this->Table->get($id1); + $obj2 = $this->Table->$model2->get($id2); + $data = [ + [ + 'id' => $id1, + 'model' => $model1 + ], + [ + 'id' => $id2, + 'model' => $model2 + ] + ]; + + try { + $savedData = $this->Table->$model2->link($obj1, [$obj2]); + } catch (Exception $e) { + $savedData = null; + } + + if (!empty($savedData)) { + $message = __('{0} attached to {1}.', $model1, $model2); + if ($this->Controller->ParamHandler->isRest()) { + $this->Controller->restResponsePayload = $this->RestResponse->viewData($message, 'json'); + } else if ($this->Controller->ParamHandler->isAjax()) { + if (!empty($params['displayOnSuccess'])) { + $displayOnSuccess = $this->renderViewInVariable($params['displayOnSuccess'], ['entity' => $data]); + $this->Controller->ajaxResponsePayload = $this->RestResponse->ajaxSuccessResponse($model1, 'index', $obj1, $message, ['displayOnSuccess' => $displayOnSuccess]); + } else { + $this->Controller->ajaxResponsePayload = $this->RestResponse->ajaxSuccessResponse($model1, 'index', $obj1, $message); + } + } else { + $this->Controller->Flash->success($message); + if (empty($params['redirect'])) { + $this->Controller->redirect(['action' => 'view', $id1]); + } else { + $this->Controller->redirect($params['redirect']); + } + } + } else { + $this->Controller->isFailResponse = true; + $message = __( + '{0} could not be attached to {1}.', + $model1, + $model2 + ); + if ($this->Controller->ParamHandler->isRest()) { + $this->Controller->restResponsePayload = $this->RestResponse->viewData($message, 'json'); + } else if ($this->Controller->ParamHandler->isAjax()) { + $this->Controller->ajaxResponsePayload = $this->RestResponse->ajaxFailResponse($model1, $functionName, $data, $message, []); + } else { + $this->Controller->Flash->error($message); + } + } + } + } + + public function unlinkObjects(string $functionName, int $id1, int $id2, string $model1, string $model2, array $params = []): void + { + $this->Controller->loadModel($model1); + $this->Controller->loadModel($model2); + $obj1 = $this->Table->get($id1); + $obj2 = $this->Table->$model2->get($id2); + $data = [ + [ + 'id' => $id1, + 'model' => $model1 + ], + [ + 'id' => $id2, + 'model' => $model2 + ] + ]; + $this->Controller->set('data', $data); + if ($this->request->is(['post', 'put'])) { + $input = $this->request->getData(); + try { + $savedData = $this->Table->$model2->unlink($obj1, [$obj2]); + } catch (Exception $e) { + $savedData = null; + } + + if (!empty($savedData)) { + $message = __('{0} detached from {1}.', $model1, $model2); + if ($this->Controller->ParamHandler->isRest()) { + $this->Controller->restResponsePayload = $this->RestResponse->viewData($message, 'json'); + } else if ($this->Controller->ParamHandler->isAjax()) { + if (!empty($params['displayOnSuccess'])) { + $displayOnSuccess = $this->renderViewInVariable($params['displayOnSuccess'], ['entity' => $data]); + $this->Controller->ajaxResponsePayload = $this->RestResponse->ajaxSuccessResponse($model1, 'index', $obj1, $message, ['displayOnSuccess' => $displayOnSuccess]); + } else { + $this->Controller->ajaxResponsePayload = $this->RestResponse->ajaxSuccessResponse($model1, 'index', $obj1, $message); + } + } else { + $this->Controller->Flash->success($message); + if (empty($params['redirect'])) { + $this->Controller->redirect(['action' => 'view', $id1]); + } else { + $this->Controller->redirect($params['redirect']); + } + } + } else { + $this->Controller->isFailResponse = true; + $message = __( + '{0} could not be detached from {1}.', + $model1, + $model2 + ); + if ($this->Controller->ParamHandler->isRest()) { + $this->Controller->restResponsePayload = $this->RestResponse->viewData($message, 'json'); + } else if ($this->Controller->ParamHandler->isAjax()) { + $this->Controller->ajaxResponsePayload = $this->RestResponse->ajaxFailResponse($model1, $functionName, $data, $message, []); + } else { + $this->Controller->Flash->error($message); + } + } + } + } } diff --git a/src/Controller/Component/Navigation/sidemenu.php b/src/Controller/Component/Navigation/sidemenu.php index ec00e27..a701104 100644 --- a/src/Controller/Component/Navigation/sidemenu.php +++ b/src/Controller/Component/Navigation/sidemenu.php @@ -27,6 +27,11 @@ class Sidemenu { 'icon' => $this->iconTable['Organisations'], 'url' => '/organisations/index', ], + 'OrgGroups' => [ + 'label' => __('Organisation Groups'), + 'icon' => $this->iconTable['OrgGroups'], + 'url' => '/orgGroups/index', + ], 'EncryptionKeys' => [ 'label' => __('Encryption keys'), 'icon' => $this->iconTable['EncryptionKeys'], diff --git a/src/Controller/Component/NavigationComponent.php b/src/Controller/Component/NavigationComponent.php index 6eaa9ff..6dea2cf 100644 --- a/src/Controller/Component/NavigationComponent.php +++ b/src/Controller/Component/NavigationComponent.php @@ -23,6 +23,7 @@ class NavigationComponent extends Component public $iconToTableMapping = [ 'Individuals' => 'address-book', 'Organisations' => 'building', + 'OrgGroups' => 'city', 'EncryptionKeys' => 'key', 'SharingGroups' => 'user-friends', 'MailingLists' => 'mail-bulk', diff --git a/src/Controller/OrgGroupsController.php b/src/Controller/OrgGroupsController.php new file mode 100644 index 0000000..105a69e --- /dev/null +++ b/src/Controller/OrgGroupsController.php @@ -0,0 +1,256 @@ + true], 'uuid']; + public $filterFields = ['name', 'uuid']; + public $containFields = ['Organisations']; + public $statisticsFields = []; + + public function index() + { + $additionalContainFields = []; + + $this->CRUD->index([ + 'filters' => $this->filterFields, + 'quickFilters' => $this->quickFilterFields, + 'quickFilterForMetaField' => ['enabled' => true, 'wildcard_search' => true], + 'contain' => $this->containFields, + 'statisticsFields' => $this->statisticsFields, + ]); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + $this->set('metaGroup', 'ContactDB'); + } + + public function filtering() + { + $this->CRUD->filtering(); + } + + public function add() + { + $this->CRUD->add(); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + } + + public function view($id) + { + $this->CRUD->view($id, ['contain' => ['Organisations']]); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + $this->set('canEdit', $this->canEdit($id)); + } + + public function edit($id) + { + if (!$this->canEdit($id)) { + throw new MethodNotAllowedException(__('You cannot modify that group.')); + } + $this->CRUD->edit($id); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + $this->set('metaGroup', 'ContactDB'); + $this->render('add'); + } + + public function delete($id) + { + $this->CRUD->delete($id); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + $this->set('metaGroup', 'ContactDB'); + } + + public function tag($id) + { + if (!$this->canEdit($id)) { + throw new MethodNotAllowedException(__('You cannot tag that organisation.')); + } + $this->CRUD->tag($id); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + } + + public function untag($id) + { + if (!$this->canEdit($id)) { + throw new MethodNotAllowedException(__('You cannot untag that organisation.')); + } + $this->CRUD->untag($id); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + } + + public function viewTags($id) + { + $this->CRUD->viewTags($id); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + } + + private function canEdit($groupId): bool + { + $currentUser = $this->ACL->getUser(); + if ($currentUser['role']['perm_admin']) { + return true; + } + if ($currentUser['role']['perm_group_admin']) { + $orgGroup = $this->OrgGroups->get($groupId, ['contain' => 'Users']); + $found = false; + foreach ($orgGroup['users'] as $admin) { + if ($admin['id'] == $currentUser['id']) { + $found = true; + } + } + return $found; + } + return false; + } + + // Listing should be available to all, it's purely informational + public function listAdmins($groupId) + { + if (empty($groupId)) { + throw new NotFoundException(__('Invalid {0}.', 'OrgGroup')); + } + $orgGroup = $this->OrgGroups->get($groupId, ['contain' => ['Users' => ['Individuals', 'Organisations']]]); + $this->set('data', $orgGroup['users']); + $this->set('canEdit', $this->ACL->getUser()['role']['perm_admin']); + $this->set('groupId', $groupId); + } + + // Listing should be available to all, it's purely informational + public function listOrgs($groupId) + { + if (empty($groupId)) { + throw new NotFoundException(__('Invalid {0}.', 'OrgGroup')); + } + $orgGroup = $this->OrgGroups->get($groupId, ['contain' => 'Organisations']); + $this->set('data', $orgGroup['organisations']); + $this->set('canEdit', $this->canEdit($groupId)); + $this->set('groupId', $groupId); + } + + public function assignAdmin($groupId) + { + if (!$this->ACL->getUser()['role']['perm_admin']) { + throw new MethodNotAllowedException(__('You do not have permission to edit this group.')); + } + $this->CRUD->linkObjects(__FUNCTION__, $groupId, 'OrgGroups', 'Users', ['redirect' => '/orgGroups/listAdmins/' . $groupId]); + if ($this->request->is('post')) { + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + } + $orgGroup = $this->OrgGroups->get($groupId, ['contain' => 'Users']); + $this->loadModel('Users'); + $this->loadModel('Roles'); + $validRoles = $this->Roles->find('list')->disableHydration()->select( + ['id', 'name'] + )->where( + ['OR' => ['perm_admin' => 1, 'perm_group_admin' => 1]] + )->toArray(); + $admins = $this->Users->find('list')->disableHydration()->select(['id', 'username'])->where(['Users.role_id IN' => array_keys($validRoles)])->toArray(); + asort($admins, SORT_STRING | SORT_FLAG_CASE); + if (!empty($orgGroup['users'])) { + foreach ($orgGroup['users'] as $admin) { + if (isset($admins[$admin['id']])) { + unset($admins[$admin['id']]); + } + } + } + $dropdownData = [ + 'admins' => $admins + ]; + $this->set(compact('dropdownData')); + } + + public function removeAdmin($groupId, $adminId) + { + if (!$this->ACL->getUser()['role']['perm_admin']) { + throw new MethodNotAllowedException(__('You do not have permission to edit this group.')); + } + $this->CRUD->unlinkObjects(__FUNCTION__, $groupId, $adminId, 'OrgGroups', 'Users'); + if ($this->request->is('post')) { + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + } + $this->viewBuilder()->setLayout('ajax'); + $this->render('/genericTemplates/detach'); + } + + public function attachOrg($groupId) + { + if (!$this->OrgGroups->checkIfGroupAdmin($groupId, $this->ACL->getUser())) { + throw new MethodNotAllowedException(__('You do not have permission to edit this group.')); + } + $this->CRUD->linkObjects(__FUNCTION__, $groupId, 'OrgGroups', 'Organisations', ['redirect' => '/orgGroups/listOrgs/' . $groupId]); + if ($this->request->is('post')) { + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + } + $orgGroup = $this->OrgGroups->get($groupId, ['contain' => 'Organisations']); + $this->loadModel('Organisations'); + $orgs = $this->Organisations->find('list')->disableHydration()->select(['id', 'name'])->toArray(); + asort($orgs, SORT_STRING | SORT_FLAG_CASE); + foreach ($orgGroup['organisations'] as $organisation) { + if (isset($orgs[$organisation['id']])) { + unset($orgs[$organisation['id']]); + } + } + $dropdownData = [ + 'orgs' => $orgs + ]; + $this->set(compact('dropdownData')); + } + + public function detachOrg($groupId, $orgId) + { + if (!$this->OrgGroups->checkIfGroupAdmin($groupId, $this->ACL->getUser())) { + throw new MethodNotAllowedException(__('You do not have permission to edit this group.')); + } + $this->CRUD->unlinkObjects(__FUNCTION__, $groupId, $orgId, 'OrgGroups', 'Organisations'); + if ($this->request->is('post')) { + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + } + $this->viewBuilder()->setLayout('ajax'); + $this->render('/genericTemplates/detach'); + } +} diff --git a/src/Controller/OrganisationsController.php b/src/Controller/OrganisationsController.php index 9739d04..9139033 100644 --- a/src/Controller/OrganisationsController.php +++ b/src/Controller/OrganisationsController.php @@ -15,7 +15,7 @@ class OrganisationsController extends AppController public $quickFilterFields = [['name' => true], 'uuid', 'nationality', 'sector', 'type', 'url']; public $filterFields = ['name', 'uuid', 'nationality', 'sector', 'type', 'url', 'Alignments.id', 'MetaFields.field', 'MetaFields.value', 'MetaFields.MetaTemplates.name']; - public $containFields = ['Alignments' => 'Individuals']; + public $containFields = ['Alignments' => 'Individuals', 'OrgGroups']; public $statisticsFields = ['nationality', 'sector']; public function index() @@ -105,7 +105,7 @@ class OrganisationsController extends AppController public function view($id) { - $this->CRUD->view($id, ['contain' => ['Alignments' => 'Individuals']]); + $this->CRUD->view($id, ['contain' => ['Alignments' => 'Individuals', 'OrgGroups']]); $responsePayload = $this->CRUD->getResponsePayload(); if (!empty($responsePayload)) { return $responsePayload; diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index 574d344..4661daf 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -13,7 +13,7 @@ class UsersController extends AppController { public $filterFields = ['Individuals.uuid', 'username', 'Individuals.email', 'Individuals.first_name', 'Individuals.last_name', 'Organisations.name', 'Organisation.nationality']; public $quickFilterFields = ['Individuals.uuid', ['username' => true], ['Individuals.first_name' => true], ['Individuals.last_name' => true], 'Individuals.email']; - public $containFields = ['Individuals', 'Roles', 'UserSettings', 'Organisations']; + public $containFields = ['Individuals', 'Roles', 'UserSettings', 'Organisations', 'OrgGroups']; public function index() { @@ -177,7 +177,7 @@ class UsersController extends AppController } } $this->CRUD->view($id, [ - 'contain' => ['Individuals' => ['Alignments' => 'Organisations'], 'Roles', 'Organisations'], + 'contain' => ['Individuals' => ['Alignments' => 'Organisations'], 'Roles', 'Organisations', 'OrgGroups'], 'afterFind' => function($data) use ($keycloakUsersParsed, $currentUser) { if (empty($currentUser['role']['perm_admin']) && $currentUser['organisation_id'] != $data['organisation_id']) { throw new NotFoundException(__('Invalid User.')); @@ -329,6 +329,9 @@ class UsersController extends AppController if (empty(Configure::read('user.allow-user-deletion'))) { throw new MethodNotAllowedException(__('User deletion is disabled on this instance.')); } + if (!$this->ACL->canEditUser($currentUser, $data)) { + throw new MethodNotAllowedException(__('You cannot edit the given user.')); + } if (!$currentUser['role']['perm_admin']) { if ($data['organisation_id'] !== $currentUser['organisation_id']) { throw new MethodNotAllowedException(__('You do not have permission to delete the given user.')); diff --git a/src/Model/Entity/OrgGroup.php b/src/Model/Entity/OrgGroup.php new file mode 100644 index 0000000..eed2a19 --- /dev/null +++ b/src/Model/Entity/OrgGroup.php @@ -0,0 +1,26 @@ + true, + 'id' => false, + 'created' => false + ]; + + protected $_accessibleOnNew = [ + 'created' => true + ]; + + public function rearrangeForAPI(array $options = []): void + { + if (!empty($this->tags)) { + $this->tags = $this->rearrangeTags($this->tags); + } + } +} diff --git a/src/Model/Table/OrgGroupsTable.php b/src/Model/Table/OrgGroupsTable.php new file mode 100644 index 0000000..47cd831 --- /dev/null +++ b/src/Model/Table/OrgGroupsTable.php @@ -0,0 +1,48 @@ +addBehavior('UUID'); + $this->addBehavior('Timestamp'); + $this->addBehavior('Tags.Tag'); + $this->addBehavior('AuditLog'); + $this->belongsToMany('Organisations', [ + 'joinTable' => 'org_groups_organisations', + ]); + $this->belongsToMany('Users', [ + 'joinTable' => 'org_groups_admins', + ]); + $this->setDisplayField('name'); + } + + public function validationDefault(Validator $validator): Validator + { + $validator + ->notEmptyString('name') + ->notEmptyString('uuid') + ->requirePresence(['name', 'uuid'], 'create'); + return $validator; + } + + public function checkIfGroupAdmin(int $groupId, mixed $user): bool + { + return true; + } + + public function checkIfUserBelongsToGroupAdminsGroup(User $currentUser, User $userToCheck): bool + { + $managedGroups = $this->find('list')->where(['Users.id' => $currentUser['id']])->select(['id', 'uuid'])->disableHydration()->toArray(); + return isset($managedGroups[$userToCheck['org_id']]); + } +} diff --git a/src/Model/Table/OrganisationsTable.php b/src/Model/Table/OrganisationsTable.php index f56f36b..0a578ca 100644 --- a/src/Model/Table/OrganisationsTable.php +++ b/src/Model/Table/OrganisationsTable.php @@ -34,6 +34,9 @@ class OrganisationsTable extends AppTable 'conditions' => ['owner_model' => 'organisation'] ] ); + $this->belongsToMany('OrgGroups', [ + 'joinTable' => 'org_groups_organisations', + ]); $this->addBehavior('MetaFields'); $this->setDisplayField('name'); } diff --git a/src/Model/Table/UsersTable.php b/src/Model/Table/UsersTable.php index cc2c0ba..e7bcb1f 100644 --- a/src/Model/Table/UsersTable.php +++ b/src/Model/Table/UsersTable.php @@ -51,6 +51,9 @@ class UsersTable extends AppTable 'strategy' => 'join' ] ); + $this->belongsToMany('OrgGroups', [ + 'joinTable' => 'org_groups_admins', + ]); $this->hasMany( 'UserSettings', [ diff --git a/templates/OrgGroups/add.php b/templates/OrgGroups/add.php new file mode 100644 index 0000000..5da45b9 --- /dev/null +++ b/templates/OrgGroups/add.php @@ -0,0 +1,25 @@ +element('genericElements/Form/genericForm', array( + 'data' => array( + 'description' => __('OrgGroups are an administrative concept, multiple organisations can belong to a grouping that allows common management by so called "GroupAdmins". This helps grouping organisations by sector, country or other commonalities into co-managed sub-communities.'), + 'model' => 'OrgGroup', + 'fields' => array( + array( + 'field' => 'name' + ), + array( + 'field' => 'uuid', + 'label' => 'UUID', + 'type' => 'uuid' + ), + array( + 'field' => 'description' + ) + ), + 'submit' => array( + 'action' => $this->request->getParam('action') + ) + ) + )); +?> + diff --git a/templates/OrgGroups/assign_admin.php b/templates/OrgGroups/assign_admin.php new file mode 100644 index 0000000..4624561 --- /dev/null +++ b/templates/OrgGroups/assign_admin.php @@ -0,0 +1,20 @@ +element('genericElements/Form/genericForm', array( + 'data' => array( + 'description' => __('Assign a user to be an administrator of the group.'), + 'model' => null, + 'fields' => array( + array( + 'field' => 'id', + 'label' => __('User'), + 'type' => 'dropdown', + 'options' => $dropdownData['admins'] + ) + ), + 'submit' => array( + 'action' => $this->request->getParam('action') + ) + ) + )); +?> + diff --git a/templates/OrgGroups/attach_org.php b/templates/OrgGroups/attach_org.php new file mode 100644 index 0000000..f594f89 --- /dev/null +++ b/templates/OrgGroups/attach_org.php @@ -0,0 +1,20 @@ +element('genericElements/Form/genericForm', array( + 'data' => array( + 'description' => __('Assign an organisation to the group.'), + 'model' => null, + 'fields' => array( + array( + 'field' => 'id', + 'label' => __('Organisation'), + 'type' => 'dropdown', + 'options' => $dropdownData['orgs'] + ) + ), + 'submit' => array( + 'action' => $this->request->getParam('action') + ) + ) + )); +?> + diff --git a/templates/OrgGroups/index.php b/templates/OrgGroups/index.php new file mode 100644 index 0000000..577e238 --- /dev/null +++ b/templates/OrgGroups/index.php @@ -0,0 +1,86 @@ +element('genericElements/IndexTable/index_table', [ + 'data' => [ + 'data' => $data, + 'top_bar' => [ + 'children' => [ + [ + 'type' => 'simple', + 'children' => [ + 'data' => [ + 'type' => 'simple', + 'text' => __('Add group'), + 'class' => 'btn btn-primary', + 'popover_url' => '/orgGroups/add' + ] + ] + ], + [ + 'type' => 'search', + 'button' => __('Search'), + 'placeholder' => __('Enter value to search'), + 'data' => '', + 'searchKey' => 'value', + 'allowFilering' => true + ], + [ + 'type' => 'table_action', + 'table_setting_id' => 'org_groups_index', + ] + ] + ], + 'fields' => [ + [ + 'name' => '#', + 'sort' => 'id', + 'class' => 'short', + 'data_path' => 'id', + ], + [ + 'name' => __('Name'), + 'class' => 'short', + 'data_path' => 'name', + 'sort' => 'name', + ], + [ + 'name' => __('UUID'), + 'sort' => 'uuid', + 'class' => 'short', + 'data_path' => 'uuid', + ], + [ + 'name' => __('Description'), + 'data_path' => 'description', + 'sort' => 'description', + ], + [ + 'name' => __('Tags'), + 'data_path' => 'tags', + 'element' => 'tags', + ], + ], + 'title' => __('Organisation Groups Index'), + 'description' => __('OrgGroups are an administrative concept, multiple organisations can belong to a grouping that allows common management by so called "GroupAdmins". This helps grouping organisations by sector, country or other commonalities into co-managed sub-communities.'), + 'actions' => [ + [ + 'url' => '/orgGroups/view', + 'url_params_data_paths' => ['id'], + 'icon' => 'eye' + ], + [ + 'open_modal' => '/orgGroups/edit/[onclick_params_data_path]', + 'modal_params_data_path' => 'id', + 'icon' => 'edit', + 'requirement' => $loggedUser['role']['perm_admin'] + ], + [ + 'open_modal' => '/orgGroups/delete/[onclick_params_data_path]', + 'modal_params_data_path' => 'id', + 'icon' => 'trash', + 'requirement' => $loggedUser['role']['perm_admin'] + ], + ] + ] +]); +echo ''; +?> diff --git a/templates/OrgGroups/list_admins.php b/templates/OrgGroups/list_admins.php new file mode 100644 index 0000000..8c1e848 --- /dev/null +++ b/templates/OrgGroups/list_admins.php @@ -0,0 +1,64 @@ +element('genericElements/IndexTable/index_table', [ + 'data' => [ + 'skip_pagination' => true, + 'data' => $data, + 'top_bar' => [ + 'children' => [ + [ + 'type' => 'simple', + 'children' => [ + 'data' => [ + 'type' => 'simple', + 'text' => __('Add Group Administrator'), + 'class' => 'btn btn-primary', + 'popover_url' => '/orgGroups/assignAdmin/' . h($groupId), + 'reload_url' => '/orgGroups/listAdmins/' . h($groupId), + ], + ], + 'requirement' => $canEdit + ] + ] + ], + 'fields' => [ + [ + 'name' => '#', + 'sort' => 'id', + 'class' => 'short', + 'data_path' => 'id', + ], + [ + 'name' => __('Username'), + 'class' => 'short', + 'data_path' => 'username', + 'sort' => 'username', + ], + [ + 'name' => __('Email'), + 'class' => 'short', + 'data_path' => 'individual.email', + 'sort' => 'individual.email', + ], + [ + 'name' => __('Organisation'), + 'sort' => 'organisation.name', + 'class' => 'short', + 'data_path' => 'organisation.name', + ] + ], + 'title' => null, + 'description' => null, + 'actions' => [ + [ + 'open_modal' => '/orgGroups/removeAdmin/' . h($groupId) . '/[onclick_params_data_path]', + 'reload_url' => '/orgGroups/listAdmins/' . h($groupId), + 'modal_params_data_path' => 'id', + 'icon' => 'unlink', + 'title' => __('Remove the administrator from the group'), + 'requirement' => $canEdit + ], + ] + ] +]); +echo ''; +?> diff --git a/templates/OrgGroups/list_orgs.php b/templates/OrgGroups/list_orgs.php new file mode 100644 index 0000000..f62e5a7 --- /dev/null +++ b/templates/OrgGroups/list_orgs.php @@ -0,0 +1,71 @@ +element('genericElements/IndexTable/index_table', [ + 'data' => [ + 'skip_pagination' => true, + 'data' => $data, + 'top_bar' => [ + 'children' => [ + [ + 'type' => 'simple', + 'children' => [ + 'data' => [ + 'type' => 'simple', + 'text' => __('Add Organisation'), + 'class' => 'btn btn-primary', + 'popover_url' => '/orgGroups/attachOrg/' . h($groupId), + 'reload_url' => '/orgGroups/listOrgs/' . h($groupId), + 'requirement' => $canEdit + ] + ] + ], + [ + 'type' => 'search', + 'button' => __('Search'), + 'placeholder' => __('Enter value to search'), + 'data' => '', + 'searchKey' => 'value', + 'allowFilering' => true + ] + ] + ], + 'fields' => [ + [ + 'name' => '#', + 'sort' => 'id', + 'class' => 'short', + 'data_path' => 'id', + ], + [ + 'name' => __('Name'), + 'class' => 'short', + 'data_path' => 'name', + 'sort' => 'name', + ], + [ + 'name' => __('UUID'), + 'sort' => 'uuid', + 'class' => 'short', + 'data_path' => 'uuid', + ] + ], + 'title' => null, + 'description' => null, + 'actions' => [ + [ + 'url' => '/organisations/view', + 'url_params_data_paths' => ['id'], + 'icon' => 'eye' + ], + [ + 'open_modal' => '/orgGroups/detachOrg/' . h($groupId) . '/[onclick_params_data_path]', + 'reload_url' => '/orgGroups/listOrgs/' . h($groupId), + 'modal_params_data_path' => 'id', + 'icon' => 'unlink', + 'title' => __('Remove organisation from the group'), + 'requirement' => $canEdit + ], + ] + ] +]); +echo ''; +?> diff --git a/templates/OrgGroups/view.php b/templates/OrgGroups/view.php new file mode 100644 index 0000000..93b9dfd --- /dev/null +++ b/templates/OrgGroups/view.php @@ -0,0 +1,44 @@ +element( + '/genericElements/SingleViews/single_view', + [ + 'title' => __('Organisation Group View'), + 'data' => $entity, + 'fields' => [ + [ + 'key' => __('ID'), + 'path' => 'id' + ], + [ + 'key' => __('Name'), + 'path' => 'name' + ], + [ + 'key' => __('UUID'), + 'path' => 'uuid' + ], + [ + 'key' => __('Description'), + 'path' => 'Description' + ], + [ + 'key' => __('Tags'), + 'type' => 'tags', + 'editable' => $canEdit, + ] + ], + 'combinedFieldsView' => false, + 'children' => [ + [ + 'url' => '/orgGroups/listAdmins/{{0}}', + 'url_params' => ['id'], + 'title' => __('Administrators') + ], + [ + 'url' => '/orgGroups/listOrgs/{{0}}', + 'url_params' => ['id'], + 'title' => __('Organisations') + ] + ] + ] +); diff --git a/templates/Organisations/index.php b/templates/Organisations/index.php index 2347b04..d0bfb0c 100644 --- a/templates/Organisations/index.php +++ b/templates/Organisations/index.php @@ -59,6 +59,14 @@ echo $this->element('genericElements/IndexTable/index_table', [ 'url' => '/individuals/index/?Organisations.id={{url_data}}', 'url_data_path' => 'id' ], + [ + 'name' => __('Group memberships'), + 'data_path' => 'org_groups', + 'data_id_sub_path' => 'id', + 'data_value_sub_path' => 'name', + 'element' => 'link_list', + 'url_pattern' => '/orgGroups/view/{{data_id}}' + ], [ 'name' => __('URL'), 'sort' => 'url', diff --git a/templates/Organisations/view.php b/templates/Organisations/view.php index 0d3d2cd..a27339b 100644 --- a/templates/Organisations/view.php +++ b/templates/Organisations/view.php @@ -1,54 +1,67 @@ __('ID'), + 'path' => 'id' + ], + [ + 'key' => __('Name'), + 'path' => 'name' + ], + [ + 'key' => __('UUID'), + 'path' => 'uuid' + ], + [ + 'key' => __('URL'), + 'path' => 'url' + ], + [ + 'key' => __('Country'), + 'path' => 'nationality' + ], + [ + 'key' => __('Sector'), + 'path' => 'sector' + ], + [ + 'key' => __('Type'), + 'path' => 'type' + ], + [ + 'key' => __('Contacts'), + 'path' => 'contacts' + ], + [ + 'key' => __('Tags'), + 'type' => 'tags', + 'editable' => $canEdit, + ], + [ + 'key' => __('Alignments'), + 'type' => 'alignment', + 'path' => '', + 'scope' => 'organisations' + ] +]; + +if (!empty($entity['org_groups'])) { + $fields[] = [ + 'type' => 'link_list', + 'key' => __('Group memberships'), + 'path' => 'org_groups', + 'data_id_sub_path' => 'id', + 'data_value_sub_path' => 'name', + 'url_pattern' => '/orgGroups/view/{{data_id}}' + ]; +} echo $this->element( '/genericElements/SingleViews/single_view', [ 'title' => __('Organisation View'), 'data' => $entity, - 'fields' => [ - [ - 'key' => __('ID'), - 'path' => 'id' - ], - [ - 'key' => __('Name'), - 'path' => 'name' - ], - [ - 'key' => __('UUID'), - 'path' => 'uuid' - ], - [ - 'key' => __('URL'), - 'path' => 'url' - ], - [ - 'key' => __('Country'), - 'path' => 'nationality' - ], - [ - 'key' => __('Sector'), - 'path' => 'sector' - ], - [ - 'key' => __('Type'), - 'path' => 'type' - ], - [ - 'key' => __('Contacts'), - 'path' => 'contacts' - ], - [ - 'key' => __('Tags'), - 'type' => 'tags', - 'editable' => $canEdit, - ], - [ - 'key' => __('Alignments'), - 'type' => 'alignment', - 'path' => '', - 'scope' => 'organisations' - ] - ], + 'fields' => $fields, 'combinedFieldsView' => false, 'children' => [] ] diff --git a/templates/Roles/add.php b/templates/Roles/add.php index c709865..f01e773 100644 --- a/templates/Roles/add.php +++ b/templates/Roles/add.php @@ -12,6 +12,11 @@ 'type' => 'checkbox', 'label' => 'Full admin privilege' ], + [ + 'field' => 'perm_group_admin', + 'type' => 'checkbox', + 'label' => 'Organisation Group admin privilege' + ], [ 'field' => 'perm_org_admin', 'type' => 'checkbox', diff --git a/templates/Roles/index.php b/templates/Roles/index.php index 02299e6..8e12ed9 100644 --- a/templates/Roles/index.php +++ b/templates/Roles/index.php @@ -50,6 +50,12 @@ echo $this->element('genericElements/IndexTable/index_table', [ 'data_path' => 'perm_admin', 'element' => 'boolean' ], + [ + 'name' => __('Group Admin'), + 'sort' => 'perm_group_admin', + 'data_path' => 'perm_group_admin', + 'element' => 'boolean' + ], [ 'name' => __('Org Admin'), 'sort' => 'perm_org_admin', diff --git a/templates/Roles/view.php b/templates/Roles/view.php index eac0175..01d0c8d 100644 --- a/templates/Roles/view.php +++ b/templates/Roles/view.php @@ -17,6 +17,11 @@ echo $this->element( 'path' => 'perm_admin', 'type' => 'boolean' ], + [ + 'key' => __('Organisation Group admin permission'), + 'path' => 'perm_group_admin', + 'type' => 'boolean' + ], [ 'key' => __('Organisation admin permission'), 'path' => 'perm_org_admin', diff --git a/templates/Users/index.php b/templates/Users/index.php index 6fae052..3cf7ab5 100644 --- a/templates/Users/index.php +++ b/templates/Users/index.php @@ -63,6 +63,14 @@ echo $this->element('genericElements/IndexTable/index_table', [ 'url' => '/organisations/view/{{0}}', 'url_vars' => ['organisation.id'] ], + [ + 'name' => __('Administered Groups'), + 'data_path' => 'org_groups', + 'data_id_sub_path' => 'id', + 'data_value_sub_path' => 'name', + 'element' => 'link_list', + 'url_pattern' => '/orgGroups/view/{{data_id}}' + ], [ 'name' => __('Email'), 'sort' => 'individual.email', diff --git a/templates/Users/view.php b/templates/Users/view.php index 5ffafda..cef8d41 100644 --- a/templates/Users/view.php +++ b/templates/Users/view.php @@ -55,6 +55,16 @@ $fields = [ 'scope' => 'individuals' ] ]; +if (!empty($entity['org_groups'])) { + $fields[] = [ + 'type' => 'link_list', + 'key' => __('Administered Groups'), + 'path' => 'org_groups', + 'data_id_sub_path' => 'id', + 'data_value_sub_path' => 'name', + 'url_pattern' => '/orgGroups/view/{{data_id}}' + ]; +} if ($keycloakConfig['enabled']) { $fields[] = [ 'key' => __('Keycloak status'), diff --git a/templates/element/genericElements/IndexTable/Fields/link_list.php b/templates/element/genericElements/IndexTable/Fields/link_list.php new file mode 100644 index 0000000..e72fd55 --- /dev/null +++ b/templates/element/genericElements/IndexTable/Fields/link_list.php @@ -0,0 +1,15 @@ +Hash->extract($row, $field['data_path']); + $links = []; + foreach ($data as $object) { + $temp_id = h($this->Hash->extract($object, $field['data_id_sub_path'])[0]); + $temp_value = h($this->Hash->extract($object, $field['data_value_sub_path'])[0]); + $url = str_replace('{{data_id}}', $temp_id, $field['url_pattern']); + $links[] = sprintf( + '%s', + $url, + $temp_value + ); + } + echo implode('
', $links); +?> diff --git a/templates/element/genericElements/ListTopBar/group_simple.php b/templates/element/genericElements/ListTopBar/group_simple.php index a364d27..5ac3e51 100644 --- a/templates/element/genericElements/ListTopBar/group_simple.php +++ b/templates/element/genericElements/ListTopBar/group_simple.php @@ -2,7 +2,9 @@ if (!isset($data['requirement']) || $data['requirement']) { $elements = ''; foreach ($data['children'] as $element) { - $elements .= $this->element('/genericElements/ListTopBar/element_' . (empty($element['type']) ? 'simple' : h($element['type'])), array('data' => $element, 'tableRandomValue' => $tableRandomValue)); + if (!isset($element['requirement']) || $element['requirement']) { + $elements .= $this->element('/genericElements/ListTopBar/element_' . (empty($element['type']) ? 'simple' : h($element['type'])), array('data' => $element, 'tableRandomValue' => $tableRandomValue)); + } } echo sprintf( '
%s
', diff --git a/templates/element/genericElements/SingleViews/Fields/link_listField.php b/templates/element/genericElements/SingleViews/Fields/link_listField.php new file mode 100644 index 0000000..b6594bd --- /dev/null +++ b/templates/element/genericElements/SingleViews/Fields/link_listField.php @@ -0,0 +1,15 @@ +Hash->extract($data, $field['path']); + $links = []; + foreach ($data as $object) { + $temp_id = h($this->Hash->extract($object, $field['data_id_sub_path'])[0]); + $temp_value = h($this->Hash->extract($object, $field['data_value_sub_path'])[0]); + $url = str_replace('{{data_id}}', $temp_id, $field['url_pattern']); + $links[] = sprintf( + '%s', + $url, + $temp_value + ); + } + echo implode('
', $links); +?> diff --git a/templates/genericTemplates/detach.php b/templates/genericTemplates/detach.php new file mode 100644 index 0000000..075b1ca --- /dev/null +++ b/templates/genericTemplates/detach.php @@ -0,0 +1,37 @@ +element('genericElements/Form/genericForm', [ + 'entity' => null, + 'ajax' => false, + 'raw' => true, + 'data' => [ + 'submit' => [ + 'action' => $this->request->getParam('action') + ] + ] +]); +$formHTML = sprintf('
%s
', $form); +$bodyMessage = !empty($deletionText) ? h($deletionText) : __( + 'Are you sure you want to detach {2} #{3} from {0} #{1}?', + h($data[0]['model']), + h($data[0]['id']), + h($data[1]['model']), + h($data[1]['id']) +); + +$bodyHTML = sprintf('%s%s', $formHTML, $bodyMessage); + +echo $this->Bootstrap->modal([ + 'size' => 'lg', + 'title' => !empty($deletionTitle) ? $deletionTitle : __( + 'Detach {0} from {1}', + h($data[0]['model']), + h($data[1]['model']), + ), + 'type' => 'confirm', + 'confirmButton' => [ + 'text' => !empty($deletionConfirm) ? $deletionConfirm : __('Detach'), + 'variant' => 'danger', + ], + 'bodyHtml' => $bodyHTML, +]); +?> From e3f8c38dcdd5d45963e4ef731880d05386d6acb7 Mon Sep 17 00:00:00 2001 From: iglocska Date: Tue, 12 Sep 2023 10:54:06 +0200 Subject: [PATCH 15/17] fix: [org admins] should be able to edit the org --- src/Controller/Component/ACLComponent.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Controller/Component/ACLComponent.php b/src/Controller/Component/ACLComponent.php index 93e01eb..2f021ce 100644 --- a/src/Controller/Component/ACLComponent.php +++ b/src/Controller/Component/ACLComponent.php @@ -181,7 +181,7 @@ class ACLComponent extends Component 'Organisations' => [ 'add' => ['perm_admin'], 'delete' => ['perm_admin'], - 'edit' => ['perm_admin'], + 'edit' => ['perm_admin', 'perm_org_admin'], 'filtering' => ['*'], 'index' => ['*'], 'tag' => ['perm_org_admin'], From c124ebc0d8084c34cb214207edca51f10309a3de Mon Sep 17 00:00:00 2001 From: iglocska Date: Tue, 12 Sep 2023 11:08:46 +0200 Subject: [PATCH 16/17] fix: [ACL] fixes --- src/Controller/OrganisationsController.php | 10 +++++++++- src/Controller/UsersController.php | 9 +++++++-- templates/Organisations/add.php | 1 + templates/Organisations/index.php | 9 ++++++++- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/Controller/OrganisationsController.php b/src/Controller/OrganisationsController.php index 9139033..aca7f36 100644 --- a/src/Controller/OrganisationsController.php +++ b/src/Controller/OrganisationsController.php @@ -118,7 +118,15 @@ class OrganisationsController extends AppController if (!$this->canEdit($id)) { throw new MethodNotAllowedException(__('You cannot modify that organisation.')); } - $this->CRUD->edit($id); + $currentUser = $this->ACL->getUser(); + $this->CRUD->edit($id, [ + 'beforeSave' => function($data) use ($currentUser) { + if (!$currentUser['role']['perm_admin']) { + unset($data['uuid']); + } + return $data; + } + ]); $responsePayload = $this->CRUD->getResponsePayload(); if (!empty($responsePayload)) { return $responsePayload; diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index 2bf70c0..5c07369 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -65,8 +65,13 @@ class UsersController extends AppController ]; $individual_ids = []; if (!$currentUser['role']['perm_admin']) { - $validRoles = $this->Users->Roles->find('list')->select(['id', 'name'])->order(['name' => 'asc'])->where(['perm_admin' => 0, 'perm_org_admin' => 0])->all()->toArray(); - $individual_ids = $this->Users->Individuals->find('aligned', ['organisation_id' => $currentUser['organisation_id']])->all()->extract('id')->toArray(); + if (!$currentUser['role']['perm_group_admin']) { + $validRoles = $this->Users->Roles->find('list')->select(['id', 'name'])->order(['name' => 'asc'])->where(['perm_admin' => 0, 'perm_group_admin' => 0])->all()->toArray(); + $individual_ids = $this->Users->Individuals->find('aligned', ['organisation_id' => $currentUser['organisation_id']])->all()->extract('id')->toArray(); + } else { + $validRoles = $this->Users->Roles->find('list')->select(['id', 'name'])->order(['name' => 'asc'])->where(['perm_admin' => 0, 'perm_group_admin' => 0, 'perm_org_admin' => 0])->all()->toArray(); + + } if (empty($individual_ids)) { $individual_ids = [-1]; } diff --git a/templates/Organisations/add.php b/templates/Organisations/add.php index 75eeee6..d8d9a99 100644 --- a/templates/Organisations/add.php +++ b/templates/Organisations/add.php @@ -12,6 +12,7 @@ 'label' => 'UUID', 'type' => 'uuid', 'tooltip' => __('If the Organisation already has a known UUID in another application such as MISP or another Cerebrate, please re-use this one.'), + 'requirements' => $loggedUser['role']['perm_admin'] ), array( 'field' => 'url' diff --git a/templates/Organisations/index.php b/templates/Organisations/index.php index d0bfb0c..e9cafb1 100644 --- a/templates/Organisations/index.php +++ b/templates/Organisations/index.php @@ -106,7 +106,14 @@ echo $this->element('genericElements/IndexTable/index_table', [ 'open_modal' => '/organisations/edit/[onclick_params_data_path]', 'modal_params_data_path' => 'id', 'icon' => 'edit', - 'requirement' => $loggedUser['role']['perm_admin'] + 'complex_requirement' => [ + 'function' => function ($row, $options) use ($loggedUser) { + if ($loggedUser['role']['perm_admin'] || ($loggedUser['role']['perm_org_admin'] && $row['id'] == $loggedUser['organisation']['id'])) { + return true; + } + return false; + } + ] ], [ 'open_modal' => '/organisations/delete/[onclick_params_data_path]', From 4d58b94d589773c7ead7132bb1f014c6e93fe6f8 Mon Sep 17 00:00:00 2001 From: iglocska Date: Tue, 12 Sep 2023 11:09:21 +0200 Subject: [PATCH 17/17] chg: [VERSION] bump --- src/VERSION.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/VERSION.json b/src/VERSION.json index 423dd7e..98cac4c 100644 --- a/src/VERSION.json +++ b/src/VERSION.json @@ -1,4 +1,4 @@ { - "version": "1.15", + "version": "1.16", "application": "Cerebrate" }