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, +]); +?>