diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 45e5fd67fe1055a03f7f5daeca663cdbf47ddf96..80e43de02e6b333441c54db6298fa4dd3888e8ed 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -21,8 +21,8 @@ before_script: - sed 's!<server>.*</server>!<server>postgres</server>!;s!<password>.*</password>!<password>maarch</password>!;s!<name>.*</name>!<name>MaarchParapheur</name>!;s!<user>.*</user>!<user>maarch</user>!;s!<enable>.*</enable>!<enable>true</enable>!' config/config.xml.default > config/config.xml - sed -i 's/rights="none" pattern="PDF"/rights="read | write" pattern="PDF"/' /etc/ImageMagick-6/policy.xml -job_php-7.4: - image: php:7.4-apache +job_php-8.1: + image: php:8.1-apache stage: test services: - name: postgres:10.1 @@ -30,18 +30,22 @@ job_php-7.4: script: - curl --location -s --output /usr/local/bin/phpunit https://phar.phpunit.de/phpunit-9.phar - chmod +x /usr/local/bin/phpunit - - phpunit --coverage-text --colors=never -c phpunit.xml - only: - - develop - except: - - schedules + - phpunit --coverage-text --colors=never + # only: + # - develop + # except: + # - schedules + rules: + - if: '$CI_COMMIT_BRANCH =~ /(feat|fix)\/[0-9]{4,5}\/develop/' + artifacts: paths: - test/unitTests/build/ expire_in: 2h + # coverage: '^\s*Lines:\s*\d+.\d+\%' -job_php-7.3: - image: php:7.3-apache +job_php-8.0: + image: php:8.0-apache stage: test services: - name: postgres:10.1 @@ -50,90 +54,118 @@ job_php-7.3: - curl --location -s --output /usr/local/bin/phpunit https://phar.phpunit.de/phpunit-9.phar - chmod +x /usr/local/bin/phpunit - phpunit --coverage-text --colors=never + # only: + # - develop + # except: + # - schedules + rules: + - if: '$CI_COMMIT_BRANCH =~ /(feat|fix)\/[0-9]{4,5}\/develop/' + artifacts: + paths: + - test/unitTests/build/ + expire_in: 2h + # coverage: '^\s*Lines:\s*\d+.\d+\%' + +job_php-7.4: + image: php:7.4-apache + stage: test + services: + - name: postgres:10.1 + command: [ "-c", "datestyle=iso,dmy" ] + script: + - curl --location -s --output /usr/local/bin/phpunit https://phar.phpunit.de/phpunit-9.phar + - chmod +x /usr/local/bin/phpunit + - phpunit --coverage-text --colors=never -c phpunit.xml + # only: + # - develop + # except: + # - schedules + rules: + - if: '$CI_COMMIT_BRANCH =~ /(feat|fix)\/[0-9]{4,5}\/develop/' + artifacts: + paths: + - test/unitTests/build/ + expire_in: 2h + # coverage: '^\s*Lines:\s*\d+.\d+\%' + +commits: + image: debian:10-slim + stage: synchronization only: - develop + - "21.03" except: + - tags - schedules - - -commits: - image: debian:10-slim - stage: synchronization - only: - - develop - - "21.03" - except: - - tags - - schedules - before_script: - # Skip the synchronisation if it is not enabled - - if [ $SYNC_ENABLED = "true" ]; then echo "Sync enabled"; else echo "Sync disabled, stopping the job" && exit 0; fi - # Configure ssh, with the private key to push to the private repository - - 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )' - - eval $(ssh-agent -s) - - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - - - mkdir -p ~/.ssh - - chmod 700 ~/.ssh - - ssh-keyscan "$GITLAB_URL" >> ~/.ssh/known_hosts - - chmod 644 ~/.ssh/known_hosts - - '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" >> ~/.ssh/config' - # Install git command - - apt install -y git - script: - - chmod +x ./ci/commit_synchronization.sh - - ./ci/commit_synchronization.sh + before_script: + # Skip the synchronisation if it is not enabled + - if [ $SYNC_ENABLED = "true" ]; then echo "Sync enabled"; else echo "Sync disabled, stopping the job" && exit 0; fi + # Configure ssh, with the private key to push to the private repository + - 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )' + - eval $(ssh-agent -s) + - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - + - mkdir -p ~/.ssh + - chmod 700 ~/.ssh + - ssh-keyscan "$GITLAB_URL" >> ~/.ssh/known_hosts + - chmod 644 ~/.ssh/known_hosts + - '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" >> ~/.ssh/config' + # Install git command + - apt install -y git + script: + - chmod +x ./ci/commit_synchronization.sh + - ./ci/commit_synchronization.sh tags: - image: debian:10-slim - stage: synchronization - only: - - tags - except: - - schedules - before_script: - # Skip the synchronisation if it is not enabled - - if [ $SYNC_ENABLED = "true" ]; then echo "Sync enabled"; else echo "Sync disabled, stopping the job" && exit 0; fi - # Configure ssh, with the private key to push to the private repository - - 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )' - - eval $(ssh-agent -s) - - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - - - mkdir -p ~/.ssh - - chmod 700 ~/.ssh - - ssh-keyscan "$GITLAB_URL" >> ~/.ssh/known_hosts - - chmod 644 ~/.ssh/known_hosts - - '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" >> ~/.ssh/config' - # Install git and curl command - - apt install -y git - - apt install -y curl - # Install npm - - curl -sL https://deb.nodesource.com/setup_14.x | bash - - - apt install -y nodejs - script: - - git config --global user.email "$CI_EMAIL" && git config --global user.name "$CI_USER" - # We will work in another directory, to avoid git conflicts - - mkdir tmp - - cd tmp - # Find the branch name from tag name - - VERSION1=$(echo $CI_COMMIT_TAG| cut -d'.' -f 1) - - VERSION2=$(echo $CI_COMMIT_TAG| cut -d'.' -f 2) - - VERSION="${VERSION1}.${VERSION2}" - # Pull the private repository - - git init && git remote add origin $PRIVATE_REPOSITORY_URL_SSH - - git pull origin $VERSION - # Update and push build prod - - npm install - - npm run build-prod - - git status - - git add -f dist/ - - git status - - git commit -m "Build prod for tag ${CI_COMMIT_TAG}" - - git show-ref - - git push origin HEAD:$VERSION - - git status - # Do the tag on the private repo - - git tag $CI_COMMIT_TAG - - git status - - git push origin --tags + image: debian:10-slim + stage: synchronization + only: + - tags + except: + - schedules + before_script: + # Skip the synchronisation if it is not enabled + - if [ $SYNC_ENABLED = "true" ]; then echo "Sync enabled"; else echo "Sync disabled, stopping the job" && exit 0; fi + # Configure ssh, with the private key to push to the private repository + - 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )' + - eval $(ssh-agent -s) + - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - + - mkdir -p ~/.ssh + - chmod 700 ~/.ssh + - ssh-keyscan "$GITLAB_URL" >> ~/.ssh/known_hosts + - chmod 644 ~/.ssh/known_hosts + - '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" >> ~/.ssh/config' + # Install git and curl command + - apt install -y git + - apt install -y curl + # Install npm + - curl -sL https://deb.nodesource.com/setup_14.x | bash - + - apt install -y nodejs + script: + - git config --global user.email "$CI_EMAIL" && git config --global user.name "$CI_USER" + # We will work in another directory, to avoid git conflicts + - mkdir tmp + - cd tmp + # Find the branch name from tag name + - VERSION1=$(echo $CI_COMMIT_TAG| cut -d'.' -f 1) + - VERSION2=$(echo $CI_COMMIT_TAG| cut -d'.' -f 2) + - VERSION="${VERSION1}.${VERSION2}" + # Pull the private repository + - git init && git remote add origin $PRIVATE_REPOSITORY_URL_SSH + - git pull origin $VERSION + # Update and push build prod + - npm install + - npm run build-prod + - git status + - git add -f dist/ + - git status + - git commit -m "Build prod for tag ${CI_COMMIT_TAG}" + - git show-ref + - git push origin HEAD:$VERSION + - git status + # Do the tag on the private repo + - git tag $CI_COMMIT_TAG + - git status + - git push origin --tags logs: @@ -177,39 +209,39 @@ logs: - curl -v -H 'Content-Type:application/json' -H "X-Redmine-API-Key:$REDMINE_API_KEY" -d "$BODY" -X PUT https://forge.maarch.org/issues/$ISSUE_ID.json new_branch: - image: debian:10-slim - stage: new_branch - only: - - branches - before_script: - # Install git and curl command - - apt-get update -yqq > /dev/null - - apt install -y curl - - apt install -y jq - script: - - chmod +x ./ci/create_mr.sh - - ./ci/create_mr.sh + image: debian:10-slim + stage: new_branch + only: + - branches + before_script: + # Install git and curl command + - apt-get update -yqq > /dev/null + - apt install -y curl + - apt install -y jq + script: + - chmod +x ./ci/create_mr.sh + - ./ci/create_mr.sh new_tag: - image: debian:10-slim - stage: new_tag - only: - - tags - before_script: - # Install git and curl command - - apt-get update -yqq > /dev/null - - 'which ssh-agent || ( apt-get install openssh-client -y )' - - eval $(ssh-agent -s) - - echo "$SSH_PRIVATE_KEY_2" | tr -d '\r' | ssh-add - - - mkdir -p ~/.ssh - - chmod 700 ~/.ssh - - ssh-keyscan "$GITLAB_URL" >> ~/.ssh/known_hosts - - chmod 644 ~/.ssh/known_hosts - - '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" >> ~/.ssh/config' - - apt install -y git - - apt install -y curl - - apt install -y jq - script: - - chmod +x ./ci/new_tag.sh - - ./ci/new_tag.sh + image: debian:10-slim + stage: new_tag + only: + - tags + before_script: + # Install git and curl command + - apt-get update -yqq > /dev/null + - 'which ssh-agent || ( apt-get install openssh-client -y )' + - eval $(ssh-agent -s) + - echo "$SSH_PRIVATE_KEY_2" | tr -d '\r' | ssh-add - + - mkdir -p ~/.ssh + - chmod 700 ~/.ssh + - ssh-keyscan "$GITLAB_URL" >> ~/.ssh/known_hosts + - chmod 644 ~/.ssh/known_hosts + - '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" >> ~/.ssh/config' + - apt install -y git + - apt install -y curl + - apt install -y jq + script: + - chmod +x ./ci/new_tag.sh + - ./ci/new_tag.sh diff --git a/lang/en.json b/lang/en.json index 24daf0836aa17d2d4dbc721e9dcdac4a3ff3282c..b2147d5c4b1c9a3aa2a7e1e3cd431f63f6b51266 100755 --- a/lang/en.json +++ b/lang/en.json @@ -648,6 +648,11 @@ "credits": "Credits", "allCredits": "And the whole Maarch community!", "licence": "GNU GPLv3 license", + "groupsToManage": "Choose the authorized assignment groups", + "unlinkGroup": "Unlink group", + "emptyGroups": "No groups available to associate", + "emptyGroupUsers": "No users associated with this group", + "emptyUsers": "No users available to associate", "can_purgeAdmin": "Logically and physically remove interrupted or terminated signature processes", "purgeDocument": "Delete the document", "downloadProofOnPurge": "You may not view the document after it is removed; its complete proof folder will be suggested for download afterwards.", diff --git a/lang/fr.json b/lang/fr.json index df76d8c0677242fe78e0a4eec339e2ce36417e4e..3f36dde67682c66bf264475bcf15da9521514028 100755 --- a/lang/fr.json +++ b/lang/fr.json @@ -647,6 +647,11 @@ "credits": "Crédits", "allCredits": "Et toute la communauté Maarch !", "licence": "licence GNU GPLv3", + "groupsToManage": "Choisir les groupes d'affectations autorisés", + "unlinkGroup": "Dissocier le groupe", + "emptyGroups": "Aucun groupe disponible à associer", + "emptyGroupUsers": "Aucun utilisateur associé à ce groupe", + "emptyUsers": "Aucun utilisateur disponible à associer", "can_purgeAdmin": "Supprimer logiquement les processus de signature interrompus ou terminés", "purgeDocument": "Supprimer le document", "downloadProofOnPurge": "Après suppression, le document ne sera plus consultable ; son dossier complet vous sera ensuite proposé au téléchargement.", diff --git a/phpunit.xml b/phpunit.xml index f062ec810c67a21709f308afaeb05b6273df07de..8648b358c763e5e56ae2432487ee1c87ec7cd8c4 100755 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,5 +1,9 @@ <?xml version="1.0" encoding="UTF-8"?> <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" colors="true" bootstrap="test/unitTests/define.php" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"> + <php> + <server name='HTTP_HOST' value='http://localhost/MaarchParapheur' /> + <server name='SERVER_PORT' value='80' /> + </php> <coverage> <include> <directory suffix="Test.php">test</directory> diff --git a/rest/index.php b/rest/index.php index b8880bdf34d07abdfbfb09cea986ffe391111cc1..51c418e3c10316f7195e59bcba0dcc3556cd0835 100755 --- a/rest/index.php +++ b/rest/index.php @@ -145,6 +145,7 @@ $app->get('/users/{id}/history', \History\controllers\HistoryController::class . $app->post('/password', \User\controllers\UserController::class . ':forgotPassword'); $app->put('/password', \User\controllers\UserController::class . ':updateForgottenPassword'); $app->put('/users/{id}/accountActivationNotification', \User\controllers\UserController::class . ':sendAccountActivationNotification'); +$app->get('/manageableGroups', \User\controllers\UserController::class . ':getManageableGroupsOfCurrentUser'); //Search $app->post('/search/documents', \Search\controllers\SearchController::class . ':getDocuments'); diff --git a/sql/data_fr.sql b/sql/data_fr.sql index 58764fa9e239bc76f058e35adfbaa29a41470cde..be8d892e4bc3a1c100070747e162091ac358ba4e 100755 --- a/sql/data_fr.sql +++ b/sql/data_fr.sql @@ -22,24 +22,24 @@ TRUNCATE TABLE external_signatory_book; INSERT INTO external_signatory_book (id, label, type, connection_data, otp_code, message_content) VALUES (1, 'Signature externe Yousign', 'yousign', '{"apiKey": "a24291dc9d3475ec4fddded67df782b9", "apiUri": "https://staging-api.yousign.com"}', '["sms", "email"]', '{"otp_sms": "eSignature : votre code de sécurité pour confirmer la signature de vos documents est {{code}}", "notification": {"body": "<div>Bonjour <span class=\"mceNonEditable\"><tag data-tag-name=\"recipient.firstname\" data-tag-type=\"string\">Prénom du destinataire</tag></span> <span class=\"mceNonEditable\"><tag data-tag-name=\"recipient.lastname\" data-tag-type=\"string\">Nom du destinataire</tag></span>,<br /><br /></div>\n<div>Des documents mis à votre disposition requièrent votre signature ou validation.<br /><br /></div>\n<div>Merci de cliquer sur le bouton ci-dessous pour les lire :<br /> <span class=\"mceNonEditable\"><tag data-tag-name=\"url\" data-tag-type=\"button\" data-tag-title=\"Accédez aux documents\">Lien d''accès</tag></span> </div>\n<div> </div>", "subject": "Document à traiter"}}'); TRUNCATE TABLE groups_privileges; -INSERT INTO groups_privileges (id, group_id, privilege) VALUES (1, 1, 'manage_users'); -INSERT INTO groups_privileges (id, group_id, privilege) VALUES (2, 1, 'manage_documents'); -INSERT INTO groups_privileges (id, group_id, privilege) VALUES (3, 1, 'manage_email_configuration'); -INSERT INTO groups_privileges (id, group_id, privilege) VALUES (4, 1, 'manage_connections'); -INSERT INTO groups_privileges (id, group_id, privilege) VALUES (5, 1, 'manage_groups'); -INSERT INTO groups_privileges (id, group_id, privilege) VALUES (7, 2, 'manage_users'); -INSERT INTO groups_privileges (id, group_id, privilege) VALUES (8, 2, 'manage_documents'); -INSERT INTO groups_privileges (id, group_id, privilege) VALUES (9, 2, 'indexation'); -INSERT INTO groups_privileges (id, group_id, privilege) VALUES (10, 2, 'manage_groups'); -INSERT INTO groups_privileges (id, group_id, privilege) VALUES (11, 1, 'manage_history'); -INSERT INTO groups_privileges (id, group_id, privilege) VALUES (12, 1, 'manage_password_rules'); -INSERT INTO groups_privileges (id, group_id, privilege) VALUES (13, 4, 'indexation'); -INSERT INTO groups_privileges (id, group_id, privilege) VALUES (14, 5, 'manage_users'); -INSERT INTO groups_privileges (id, group_id, privilege) VALUES (15, 5, 'manage_documents'); -INSERT INTO groups_privileges (id, group_id, privilege) VALUES (17, 5, 'manage_history'); -INSERT INTO groups_privileges (id, group_id, privilege) VALUES (18, 1, 'indexation'); -INSERT INTO groups_privileges (id, group_id, privilege) VALUES (19, 1, 'manage_notifications'); -INSERT INTO groups_privileges (id, group_id, privilege) VALUES (20, 1, 'manage_customization'); +INSERT INTO groups_privileges (id, group_id, privilege, parameters) VALUES (1, 1, 'manage_users', '{}'); +INSERT INTO groups_privileges (id, group_id, privilege, parameters) VALUES (2, 1, 'manage_documents', '{}'); +INSERT INTO groups_privileges (id, group_id, privilege, parameters) VALUES (3, 1, 'manage_email_configuration', '{}'); +INSERT INTO groups_privileges (id, group_id, privilege, parameters) VALUES (4, 1, 'manage_connections', '{}'); +INSERT INTO groups_privileges (id, group_id, privilege, parameters) VALUES (5, 1, 'manage_groups', '{}'); +INSERT INTO groups_privileges (id, group_id, privilege, parameters) VALUES (7, 2, 'manage_users', '{}'); +INSERT INTO groups_privileges (id, group_id, privilege, parameters) VALUES (8, 2, 'manage_documents', '{}'); +INSERT INTO groups_privileges (id, group_id, privilege, parameters) VALUES (9, 2, 'indexation', '{}'); +INSERT INTO groups_privileges (id, group_id, privilege, parameters) VALUES (10, 2, 'manage_groups', '{}'); +INSERT INTO groups_privileges (id, group_id, privilege, parameters) VALUES (11, 1, 'manage_history', '{}'); +INSERT INTO groups_privileges (id, group_id, privilege, parameters) VALUES (12, 1, 'manage_password_rules', '{}'); +INSERT INTO groups_privileges (id, group_id, privilege, parameters) VALUES (13, 4, 'indexation', '{}'); +INSERT INTO groups_privileges (id, group_id, privilege, parameters) VALUES (14, 5, 'manage_users', '{}'); +INSERT INTO groups_privileges (id, group_id, privilege, parameters) VALUES (15, 5, 'manage_documents', '{}'); +INSERT INTO groups_privileges (id, group_id, privilege, parameters) VALUES (17, 5, 'manage_history', '{}'); +INSERT INTO groups_privileges (id, group_id, privilege, parameters) VALUES (18, 1, 'indexation', '{}'); +INSERT INTO groups_privileges (id, group_id, privilege, parameters) VALUES (19, 1, 'manage_notifications', '{}'); +INSERT INTO groups_privileges (id, group_id, privilege, parameters) VALUES (20, 1, 'manage_customization', '{}'); TRUNCATE TABLE password_rules; diff --git a/sql/structure.sql b/sql/structure.sql index a54dccfc5e5cea27cc80ce128fc97be6faa074f9..a8ebd68a8c659196a614aad3a9dd99a4e6abaaef 100755 --- a/sql/structure.sql +++ b/sql/structure.sql @@ -127,6 +127,7 @@ CREATE TABLE groups_privileges id serial NOT NULL, group_id INTEGER NOT NULL, privilege character varying(128) NOT NULL, + parameters jsonb DEFAULT '{}' NOT NULL, CONSTRAINT groups_privileges_pkey PRIMARY KEY (id), CONSTRAINT groups_privileges_unique_key UNIQUE (group_id, privilege) ) diff --git a/src/app/email/controllers/EmailController.php b/src/app/email/controllers/EmailController.php index 8601bbad15c93bf1ec57b8472437354d4dcca8dc..b4cc19f7e9b6cac8f25bc82c286efd26fa290a7d 100644 --- a/src/app/email/controllers/EmailController.php +++ b/src/app/email/controllers/EmailController.php @@ -132,10 +132,10 @@ class EmailController $email['cci'] = json_decode($email['cci']); $configuration = ConfigurationModel::getByIdentifier(['identifier' => 'emailServer', 'select' => ['value']]); - $configuration = json_decode($configuration[0]['value'], true); if (empty($configuration)) { return ['errors' => 'Configuration is missing']; } + $configuration = json_decode($configuration[0]['value'], true); $phpmailer = new PHPMailer(); $phpmailer->setFrom($configuration['from']); diff --git a/src/app/group/controllers/GroupController.php b/src/app/group/controllers/GroupController.php index 122722e88a2c4a0f00c69c1a04be39b76beb66c7..4fa407b5ee4367b484fcede562f7e6423725459d 100755 --- a/src/app/group/controllers/GroupController.php +++ b/src/app/group/controllers/GroupController.php @@ -20,6 +20,7 @@ use History\controllers\HistoryController; use Respect\Validation\Validator; use Slim\Http\Request; use Slim\Http\Response; +use User\controllers\UserController; use User\models\UserGroupModel; use User\models\UserModel; @@ -56,13 +57,20 @@ class GroupController 'select' => ['users.id', 'users.firstname', 'users.lastname'] ]); - $groupPrivileges = GroupPrivilegeModel::getPrivilegesByGroupId(['groupId' => $args['id']]); - $groupPrivileges = array_column($groupPrivileges, 'privilege'); + $groupPrivileges = GroupPrivilegeModel::getPrivilegesByGroupId([ + 'select' => ['privilege', 'parameters'], + 'groupId' => $args['id'] + ]); + $groupPrivileges = array_column($groupPrivileges, 'parameters', 'privilege'); + $groupPrivileges = array_map(function ($parameters) { + return json_decode($parameters, 'true'); + }, $groupPrivileges); $aPrivileges = PrivilegeController::PRIVILEGES; foreach ($aPrivileges as $key => $value) { - if (in_array($value['id'], $groupPrivileges)) { + if (array_key_exists($value['id'], $groupPrivileges)) { $aPrivileges[$key]['checked'] = true; + $aPrivileges[$key]['parameters'] = $groupPrivileges[$value['id']]; } else { $aPrivileges[$key]['checked'] = false; } @@ -172,7 +180,7 @@ class GroupController return $response->withStatus(204); } - public function updateGroupPrivilege(Request $request, Response $response, array $aArgs) + public function updateGroupPrivilege(Request $request, Response $response, array $args) { if (!PrivilegeController::hasPrivilege(['userId' => $GLOBALS['id'], 'privilege' => 'manage_groups'])) { return $response->withStatus(403)->withJson(['errors' => 'Privilege forbidden']); @@ -182,73 +190,106 @@ class GroupController if (empty($body)) { return $response->withStatus(400)->withJson(['errors' => 'Body is not set or empty']); - } elseif (!Validator::stringType()->notEmpty()->validate($aArgs['privilegeId'])) { + } elseif (!Validator::stringType()->notEmpty()->validate($args['privilegeId'])) { return $response->withStatus(400)->withJson(['errors' => 'privilegeId is empty']); } elseif (!Validator::boolType()->validate($body['checked'])) { return $response->withStatus(400)->withJson(['errors' => 'Body checked is empty']); - } elseif (!Validator::intVal()->notEmpty()->validate($aArgs['id'])) { + } elseif (!Validator::intVal()->notEmpty()->validate($args['id'])) { return $response->withStatus(400)->withJson(['errors' => 'Id must be an integer']); } - $group = GroupModel::getById(['id' => $aArgs['id']]); + $group = GroupModel::getById(['id' => $args['id']]); if (empty($group)) { return $response->withStatus(400)->withJson(['errors' => 'Group not found']); } - if ($body['checked'] === true && !empty(GroupPrivilegeModel::getPrivileges(['select' => [1], 'where' => ['privilege = ?', 'group_id = ?'], 'data' => [$aArgs['privilegeId'], $aArgs['id']]]))) { + $parameters = $body['parameters'] ?? []; + $parametersJson = empty($parameters) ? '{}' : json_encode($parameters); + + $privilege = GroupPrivilegeModel::getPrivileges([ + 'select' => ['parameters'], + 'where' => [ + 'privilege = ?', + 'group_id = ?' + ], + 'data' => [ + $args['privilegeId'], + $args['id'] + ] + ]); + $privilege = $privilege[0] ?? null; + + if ($body['checked'] === true && !empty($privilege) && $privilege['parameters'] == $parametersJson) { return $response->withStatus(400)->withJson(['errors' => 'Privilege is already linked to this group']); } if ($body['checked']) { - GroupPrivilegeModel::addPrivilege(['groupId' => $aArgs['id'], 'privilegeId' => $aArgs['privilegeId']]); + if (!PrivilegeController::hasRightByPrivilege(['userId' => $GLOBALS['id'], 'groupId' => $args['id'], 'privilegeId' => $args['privilegeId'], 'parameters' => $parameters])) { + return $response->withStatus(400)->withJson(['errors' => 'Privilege not allowed with these parameters']); + } + if (empty($privilege)) { + GroupPrivilegeModel::addPrivilege(['groupId' => $args['id'], 'privilegeId' => $args['privilegeId']]); + } + GroupPrivilegeModel::updateParameters(['groupId' => $args['id'], 'privilegeId' => $args['privilegeId'], 'parameters' => $parametersJson]); } else { - GroupPrivilegeModel::deletePrivilege(['groupId' => $aArgs['id'], 'privilegeId' => $aArgs['privilegeId']]); + GroupPrivilegeModel::deletePrivilege(['groupId' => $args['id'], 'privilegeId' => $args['privilegeId']]); } HistoryController::add([ 'code' => 'OK', 'objectType' => 'groups', - 'objectId' => $aArgs['id'], + 'objectId' => $args['id'], 'type' => 'MODIFICATION', - 'message' => "{privilegeUpdated} : {$aArgs['privilegeId']}" + 'message' => "{privilegeUpdated} : {$args['privilegeId']}" ]); return $response->withStatus(204); } - public function addUser(Request $request, Response $response, array $aArgs) + public function addUser(Request $request, Response $response, array $args) { - if (!PrivilegeController::hasPrivilege(['userId' => $GLOBALS['id'], 'privilege' => 'manage_groups']) && !PrivilegeController::hasPrivilege(['userId' => $GLOBALS['id'], 'privilege' => 'manage_users'])) { - return $response->withStatus(403)->withJson(['errors' => 'Privilege forbidden']); - } - $body = $request->getParsedBody(); - if (!Validator::intVal()->notEmpty()->validate($aArgs['id'])) { + if (empty($args['id']) || !Validator::intVal()->validate($args['id'])) { return $response->withStatus(400)->withJson(['errors' => 'Id must be an integer']); - } elseif (!Validator::intVal()->notEmpty()->validate($body['userId'])) { - return $response->withStatus(400)->withJson(['errors' => 'userId must be an integer']); } - $group = GroupModel::getById(['id' => $aArgs['id']]); + $hasGroupPrivilege = PrivilegeController::hasPrivilege(['userId' => $GLOBALS['id'], 'privilege' => 'manage_groups']); + $hasUserPrivilege = false; + + $manageableGroups = array_column(UserController::getManageableGroups(['userId' => $GLOBALS['id']]), 'id'); + $targetUserGroups = UserGroupModel::get([ + 'select' => ['group_id'], + 'where' => ['user_id = ?'], + 'data' => [$args['userId']] + ]); + $targetUserGroups = array_column($targetUserGroups, 'group_id'); + if (in_array($args['id'], $manageableGroups) && in_array($args['id'], $targetUserGroups)) { + $hasUserPrivilege = true; + } + if (!$hasGroupPrivilege && !$hasUserPrivilege) { + return $response->withStatus(403)->withJson(['errors' => 'Privilege forbidden']); + } + + $group = GroupModel::getById(['id' => $args['id']]); $user = UserModel::getById(['id' => $body['userId'], 'select' => ['firstname', 'lastname']]); if (empty($group)) { return $response->withStatus(400)->withJson(['errors' => 'Group not found']); } elseif (empty($user)) { return $response->withStatus(400)->withJson(['errors' => 'User not found']); - } elseif (UserGroupModel::hasGroup(['groupId' => $aArgs['id'], 'userId' => $body['userId']])) { + } elseif (UserGroupModel::hasGroup(['groupId' => $args['id'], 'userId' => $body['userId']])) { return $response->withStatus(400)->withJson(['errors' => 'This user already has this group']); } UserGroupModel::addUser([ - 'groupId' => $aArgs['id'], + 'groupId' => $args['id'], 'userId' => $body['userId'] ]); HistoryController::add([ 'code' => 'OK', 'objectType' => 'groups', - 'objectId' => $aArgs['id'], + 'objectId' => $args['id'], 'type' => 'MODIFICATION', 'message' => "{userAddedToGroup} : {$user['firstname']} {$user['lastname']}" ]); @@ -264,20 +305,18 @@ class GroupController return $response->withStatus(204); } - public function removeUser(Request $request, Response $response, array $aArgs) + public function removeUser(Request $request, Response $response, array $args) { - if (!PrivilegeController::hasPrivilege(['userId' => $GLOBALS['id'], 'privilege' => 'manage_groups']) && !PrivilegeController::hasPrivilege(['userId' => $GLOBALS['id'], 'privilege' => 'manage_users'])) { - return $response->withStatus(403)->withJson(['errors' => 'Privilege forbidden']); - } - - if (!Validator::intVal()->notEmpty()->validate($aArgs['id'])) { + if (empty($args['id']) || !Validator::intVal()->validate($args['id'])) { return $response->withStatus(400)->withJson(['errors' => 'Id must be an integer']); - } elseif (!Validator::intVal()->notEmpty()->validate($aArgs['userId'])) { + } elseif (empty($args['userId']) || !Validator::intVal()->validate($args['userId'])) { return $response->withStatus(400)->withJson(['errors' => 'userId must be an integer']); + } elseif (!UserController::hasRightByUserId(['activeUserId' => $GLOBALS['id'], 'targetUserId' => $args['userId'], 'targetGroupId' => $args['id']])) { + return $response->withStatus(403)->withJson(['errors' => 'Privilege forbidden']); } - $group = GroupModel::getById(['id' => $aArgs['id']]); - $user = UserModel::getById(['id' => $aArgs['userId'], 'select' => ['firstname', 'lastname']]); + $group = GroupModel::getById(['id' => $args['id']]); + $user = UserModel::getById(['id' => $args['userId'], 'select' => ['firstname', 'lastname']]); if (empty($group)) { return $response->withStatus(400)->withJson(['errors' => 'Group not found']); @@ -286,14 +325,14 @@ class GroupController } UserGroupModel::removeUser([ - 'groupId' => $aArgs['id'], - 'userId' => $aArgs['userId'] + 'groupId' => $args['id'], + 'userId' => $args['userId'] ]); HistoryController::add([ 'code' => 'OK', 'objectType' => 'groups', - 'objectId' => $aArgs['id'], + 'objectId' => $args['id'], 'type' => 'MODIFICATION', 'message' => "{removedFromGroup} : {$user['firstname']} {$user['lastname']}" ]); @@ -301,7 +340,7 @@ class GroupController HistoryController::add([ 'code' => 'OK', 'objectType' => 'users', - 'objectId' => $aArgs['userId'], + 'objectId' => $args['userId'], 'type' => 'MODIFICATION', 'message' => "{removedFromGroup} : {$group['label']}" ]); diff --git a/src/app/group/controllers/PrivilegeController.php b/src/app/group/controllers/PrivilegeController.php index c02ae36e50fedd278b113f050ccc7ca171094cf1..ca2773ec5c5df691396cd51808f0955690ca9419 100755 --- a/src/app/group/controllers/PrivilegeController.php +++ b/src/app/group/controllers/PrivilegeController.php @@ -17,6 +17,7 @@ namespace Group\controllers; use SrcCore\models\ValidatorModel; use User\models\UserGroupModel; use Group\models\GroupPrivilegeModel; +use User\controllers\UserController; class PrivilegeController { @@ -79,4 +80,30 @@ class PrivilegeController return false; } + + public static function hasRightByPrivilege(array $args) + { + ValidatorModel::notEmpty($args, ['userId', 'groupId', 'privilegeId']); + ValidatorModel::intVal($args, ['userId', 'groupId']); + ValidatorModel::stringType($args, ['privilegeId']); + + if ($args['privilegeId'] == 'manage_users') { + if (empty($args['readOnly']) && !isset($args['parameters']['authorized'])) { + return false; + } + if (PrivilegeController::hasPrivilege(['userId' => $args['userId'], 'privilege' => 'manage_groups'])) { + return true; + } elseif (!PrivilegeController::hasPrivilege(['userId' => $args['userId'], 'privilege' => 'manage_users'])) { + return false; + } else { + $candidateGroups = $args['parameters']['authorized'] ?? []; + $manageableGroups = array_column(UserController::getManageableGroups(['userId' => $args['userId']]), 'id'); + if (!empty(array_diff($candidateGroups, $manageableGroups))) { + return false; + } + } + } + + return true; + } } diff --git a/src/app/group/models/GroupPrivilegeModel.php b/src/app/group/models/GroupPrivilegeModel.php index e831de8c3eaf58af793e2ab646887f195b01a0bf..0b7698485e735b2377ee2e11c43c0d7459af7313 100755 --- a/src/app/group/models/GroupPrivilegeModel.php +++ b/src/app/group/models/GroupPrivilegeModel.php @@ -95,4 +95,20 @@ class GroupPrivilegeModel return true; } + + public static function updateParameters(array $args) + { + ValidatorModel::notEmpty($args, ['groupId', 'privilegeId']); + ValidatorModel::intVal($args, ['groupId']); + ValidatorModel::stringType($args, ['privilegeId', 'parameters']); + + DatabaseModel::update([ + 'table' => 'groups_privileges', + 'where' => ['group_id = ?', 'privilege = ?'], + 'data' => [$args['groupId'], $args['privilegeId']], + 'set' => ['parameters' => $args['parameters']] + ]); + + return true; + } } diff --git a/src/app/history/controllers/HistoryController.php b/src/app/history/controllers/HistoryController.php index 6ea5807dc1147068d7a72b33f18781603bf3c23b..a7df7fa2e01c321ccf9e54a0bb2c55f8e36f2557 100644 --- a/src/app/history/controllers/HistoryController.php +++ b/src/app/history/controllers/HistoryController.php @@ -38,6 +38,7 @@ use SrcCore\models\PasswordModel; use SrcCore\models\TextFormatModel; use SrcCore\models\ValidatorModel; use User\models\UserModel; +use User\controllers\UserController; use Workflow\controllers\YousignController; use Workflow\models\WorkflowExternalInformationModel; use Workflow\models\WorkflowModel; @@ -674,6 +675,9 @@ class HistoryController if ($GLOBALS['id'] != $args['id'] && !PrivilegeController::hasPrivilege(['userId' => $GLOBALS['id'], 'privilege' => 'manage_users'])) { return $response->withStatus(403)->withJson(['errors' => 'Privilege forbidden']); } + if (!UserController::hasRightByUserId(['activeUserId' => $GLOBALS['id'], 'targetUserId' => $args['id']])) { + return $response->withStatus(403)->withJson(['errors' => 'Privilege forbidden']); + } if (!Validator::intVal()->notEmpty()->validate($args['id'])) { return $response->withStatus(400)->withJson(['errors' => 'Route id is not an integer']); diff --git a/src/app/user/controllers/SignatureController.php b/src/app/user/controllers/SignatureController.php index c8c15d4bcde07fd74fa4d34ea7696a2316138ff1..060b20e2dfe28ea44afb8a2a2fc0883a51bc0388 100755 --- a/src/app/user/controllers/SignatureController.php +++ b/src/app/user/controllers/SignatureController.php @@ -25,6 +25,7 @@ use SrcCore\models\CoreConfigModel; use SrcCore\models\ValidatorModel; use User\models\SignatureModel; use User\models\UserModel; +use User\controllers\UserController; class SignatureController { @@ -77,6 +78,9 @@ class SignatureController if ($GLOBALS['id'] != $args['id'] && !PrivilegeController::hasPrivilege(['userId' => $GLOBALS['id'], 'privilege' => 'manage_users'])) { return $response->withStatus(403)->withJson(['errors' => 'Privilege forbidden']); } + if (!UserController::hasRightByUserId(['activeUserId' => $GLOBALS['id'], 'targetUserId' => $args['id']])) { + return $response->withStatus(403)->withJson(['errors' => 'Privilege forbidden']); + } $body = $request->getParsedBody(); @@ -132,6 +136,9 @@ class SignatureController if ($GLOBALS['id'] != $args['id'] && !PrivilegeController::hasPrivilege(['userId' => $GLOBALS['id'], 'privilege' => 'manage_users'])) { return $response->withStatus(403)->withJson(['errors' => 'Privilege forbidden']); } + if (!UserController::hasRightByUserId(['activeUserId' => $GLOBALS['id'], 'targetUserId' => $args['id']])) { + return $response->withStatus(403)->withJson(['errors' => 'Privilege forbidden']); + } SignatureModel::delete(['where' => ['user_id = ?', 'id = ?'], 'data' => [$args['id'], $args['signatureId']]]); @@ -152,6 +159,9 @@ class SignatureController if ($GLOBALS['id'] != $args['id'] && !PrivilegeController::hasPrivilege(['userId' => $GLOBALS['id'], 'privilege' => 'manage_users'])) { return $response->withStatus(403)->withJson(['errors' => 'Privilege forbidden']); } + if (!UserController::hasRightByUserId(['activeUserId' => $GLOBALS['id'], 'targetUserId' => $args['id']])) { + return $response->withStatus(403)->withJson(['errors' => 'Privilege forbidden']); + } $user = UserModel::getById(['select' => [1], 'id' => $args['id']]); if (empty($user)) { @@ -212,6 +222,9 @@ class SignatureController if ($GLOBALS['id'] != $args['id'] && !PrivilegeController::hasPrivilege(['userId' => $GLOBALS['id'], 'privilege' => 'manage_users'])) { return $response->withStatus(403)->withJson(['errors' => 'Privilege forbidden']); } + if (!UserController::hasRightByUserId(['activeUserId' => $GLOBALS['id'], 'targetUserId' => $args['id']])) { + return $response->withStatus(403)->withJson(['errors' => 'Privilege forbidden']); + } $user = UserModel::getById(['select' => [1], 'id' => $args['id']]); if (empty($user)) { diff --git a/src/app/user/controllers/UserController.php b/src/app/user/controllers/UserController.php index b24c49658fc38943e7e7567ebde6fcc1ac0f416b..ed9398ada46fd0c28a6943bd3b2d84a1aa0aa170 100755 --- a/src/app/user/controllers/UserController.php +++ b/src/app/user/controllers/UserController.php @@ -21,6 +21,7 @@ use Email\controllers\EmailController; use Firebase\JWT\JWT; use Group\controllers\PrivilegeController; use Group\models\GroupModel; +use Group\models\GroupPrivilegeModel; use History\controllers\HistoryController; use Respect\Validation\Validator; use Slim\Http\Request; @@ -28,7 +29,6 @@ use Slim\Http\Response; use SrcCore\controllers\AuthenticationController; use SrcCore\controllers\LanguageController; use SrcCore\controllers\PasswordController; -use SrcCore\controllers\UrlController; use SrcCore\models\AuthenticationModel; use SrcCore\models\CoreConfigModel; use SrcCore\models\PasswordModel; @@ -66,6 +66,10 @@ class UserController ]); $currentUser = UserModel::getById(['select' => ['"isRest"'], 'id' => $GLOBALS['id']]); + $manageableGroups = UserController::getManageableGroups(['userId' => $GLOBALS['id']]); + $manageableGroups = array_column($manageableGroups, 'id'); + $allGroupLabels = GroupModel::get(['select' => ['label', 'id']]); + $allUsersGroups = UserGroupModel::get(['select' => ['user_id', 'group_id']]); foreach ($users as $key => $user) { $users[$key]['substitute'] = !empty($user['substitute']); @@ -73,9 +77,27 @@ class UserController $users[$key]['x509Fingerprint'] = $users[$key]['x509_fingerprint']; } unset($users[$key]['x509_fingerprint']); + + $users[$key]['groups'] = []; + $groupsIds = array_column(array_filter($allUsersGroups, function ($userGroup) use ($user) { + return $userGroup['user_id'] == $user['id']; + }), 'group_id'); + $actuallyAlone = false; + if (empty($groupsIds)) { + $actuallyAlone = true; + } elseif ($GLOBALS['id'] != $user['id']) { + $groupsIds = array_values(array_intersect($groupsIds, $manageableGroups)); + } + if (!empty($groupsIds)) { + $users[$key]['groups'] = array_filter($allGroupLabels, function ($group) use ($groupsIds) { + return in_array($group['id'], $groupsIds); + }); + } elseif (!$actuallyAlone) { + unset($users[$key]); + } } - return $response->withJson(['users' => $users]); + return $response->withJson(['users' => array_values($users)]); } public function getById(Request $request, Response $response, array $args) @@ -94,14 +116,22 @@ class UserController return $response->withStatus(400)->withJson(['errors' => 'User does not exist']); } - if ($GLOBALS['id'] == $args['id'] || PrivilegeController::hasPrivilege(['userId' => $GLOBALS['id'], 'privilege' => 'manage_users'])) { - $user['groups'] = []; - $userGroups = UserGroupModel::get(['select' => ['group_id'], 'where' => ['user_id = ?'], 'data' => [$args['id']]]); - $groupsIds = array_column($userGroups, 'group_id'); - if (!empty($groupsIds)) { - $groups = GroupModel::get(['select' => ['label'], 'where' => ['id in (?)'], 'data' => [$groupsIds]]); - $user['groups'] = $groups; - } + $user['groups'] = []; + $userGroups = UserGroupModel::get(['select' => ['group_id'], 'where' => ['user_id = ?'], 'data' => [$args['id']]]); + $groupsIds = array_column($userGroups, 'group_id'); + + $actuallyAlone = false; + if (empty($groupsIds)) { + $actuallyAlone = true; + } elseif ($GLOBALS['id'] != $args['id']) { + $groupsIds = array_values(array_intersect($groupsIds, array_column(UserController::getManageableGroups(['userId' => $GLOBALS['id']]), 'id'))); + } + + if (!empty($groupsIds)) { + $groups = GroupModel::get(['select' => ['label', 'id'], 'where' => ['id in (?)'], 'data' => [$groupsIds]]); + $user['groups'] = $groups; + } elseif (!$actuallyAlone) { + return $response->withStatus(403)->withJson(['errors' => 'User out of perimeter']); } HistoryController::add([ @@ -135,15 +165,25 @@ class UserController return $response->withStatus(400)->withJson(['errors' => 'Body email is empty or not a valid email']); } elseif (!empty($body['x509Fingerprint']) && !Validator::stringType()->validate($body['x509Fingerprint'])) { return $response->withStatus(400)->withJson(['errors' => 'Body x509Fingerprint is not a string']); + } elseif (!empty($body['groups']) && !Validator::arrayType()->validate($body['groups'])) { + return $response->withStatus(400)->withJson(['errors' => 'Body groups is not an array']); } + $body['groups'] = !empty($body['groups']) ? array_column($body['groups'], 'id') : []; + $manageableGroups = array_column(UserController::getManageableGroups(['userId' => $GLOBALS['id']]), 'id'); + $body['groups'] = array_values(array_intersect($body['groups'], $manageableGroups)); + $body['login'] = strtolower($body['login']); $existingUser = UserModel::getByLogin(['login' => $body['login'], 'select' => [1]]); if (!empty($existingUser)) { return $response->withStatus(400)->withJson(['errors' => 'Login already exists', 'lang' => 'userLoginAlreadyExists']); } - $body['x509_fingerprint'] = $body['x509Fingerprint']; + $body['x509_fingerprint'] = !empty($body['x509Fingerprint']) ? $body['x509Fingerprint'] : null; + + if (empty($body['phone'])) { + $body['phone'] = null; + } if (!empty($body['isRest'])) { $body['"isRest"'] = true; @@ -189,7 +229,7 @@ class UserController public function update(Request $request, Response $response, array $args) { $connection = ConfigurationModel::getConnection(); - if (($GLOBALS['id'] != $args['id'] || $connection != 'default') && !PrivilegeController::hasPrivilege(['userId' => $GLOBALS['id'], 'privilege' => 'manage_users'])) { + if (($GLOBALS['id'] != $args['id'] || $connection != 'default') && !UserController::hasRightByUserId(['activeUserId' => $GLOBALS['id'], 'targetUserId' => $args['id']])) { return $response->withStatus(403)->withJson(['errors' => 'Privilege forbidden']); } @@ -205,6 +245,8 @@ class UserController return $response->withStatus(400)->withJson(['errors' => 'Body email is empty or not a valid email']); } elseif (!empty($body['x509Fingerprint']) && !Validator::stringType()->validate($body['x509Fingerprint'])) { return $response->withStatus(400)->withJson(['errors' => 'Body x509Fingerprint is not a string']); + } elseif (!Validator::arrayType()->each(Validator::intType())->validate($body['groups'])) { + return $response->withStatus(400)->withJson(['errors' => 'Body groups is not an array of integers']); } $user = UserModel::getById(['id' => $args['id'], 'select' => [1]]); @@ -216,7 +258,6 @@ class UserController 'firstname' => $body['firstname'], 'lastname' => $body['lastname'], 'email' => $body['email'], - 'phone' => $body['phone'], 'signature_modes' => [] ]; @@ -266,6 +307,30 @@ class UserController 'data' => [$args['id']] ]); + if (!empty($body['groups']) && Validator::arrayType()->each(Validator::intType())->validate($body['groups'])) { + /** + * Alice wants to edit Bob’s groups. + * + * B: $body[groups]: groups Alice wants to apply to Bob + * M: $GLOBALS[id] manageable groups: groups Alice has right on + * C: current groups: Bob’s current groups + * + * given these, Bob’s new groups are the groups Alice asked for, + * plus “Bob’s current groups except groups Alice has right onâ€: + * + * B union (C - M) + */ + $targetCurrentGroups = UserGroupModel::get([ + 'select' => ['group_id'], + 'where' => ['user_id = ?'], + 'data' => [$args['id']] + ]); + $targetCurrentGroups = !empty($targetCurrentGroups) ? array_column($targetCurrentGroups, 'group_id') : []; + $manageableGroups = array_column(UserController::getManageableGroups(['userId' => $GLOBALS['id']]), 'id'); + $appliedGroups = array_unique(array_merge($body['groups'], array_diff($targetCurrentGroups, $manageableGroups))); + UserGroupModel::setUserGroups(['userId' => $args['id'], 'groups' => $appliedGroups]); + } + HistoryController::add([ 'code' => 'OK', 'objectType' => 'users', @@ -339,7 +404,7 @@ class UserController public function delete(Request $request, Response $response, array $args) { - if (!PrivilegeController::hasPrivilege(['userId' => $GLOBALS['id'], 'privilege' => 'manage_users']) || $GLOBALS['id'] == $args['id']) { + if ($GLOBALS['id'] == $args['id'] || !UserController::hasRightByUserId(['activeUserId' => $GLOBALS['id'], 'targetUserId' => $args['id']])) { return $response->withStatus(403)->withJson(['errors' => 'Privilege forbidden']); } @@ -444,7 +509,7 @@ class UserController return $response->withStatus(400)->withJson(['errors' => 'Route id is not an integer']); } - if (!PrivilegeController::hasPrivilege(['userId' => $GLOBALS['id'], 'privilege' => 'manage_users']) && $GLOBALS['id'] != $args['id']) { + if ($GLOBALS['id'] != $args['id'] && !UserController::hasRightByUserId(['activeUserId' => $GLOBALS['id'], 'targetUserId' => $args['id']])) { return $response->withStatus(403)->withJson(['errors' => 'Privilege forbidden']); } @@ -591,7 +656,7 @@ class UserController } if ($GLOBALS['id'] != $args['id']) { - if (!PrivilegeController::hasPrivilege(['userId' => $GLOBALS['id'], 'privilege' => 'manage_users'])) { + if (!UserController::hasRightByUserId(['activeUserId' => $GLOBALS['id'], 'targetUserId' => $args['id']])) { return $response->withStatus(403)->withJson(['errors' => 'Privilege forbidden']); } } @@ -740,12 +805,12 @@ class UserController public function sendAccountActivationNotification(Request $request, Response $response, array $args) { - if (!PrivilegeController::hasPrivilege(['userId' => $GLOBALS['id'], 'privilege' => 'manage_users'])) { - return $response->withStatus(403)->withJson(['errors' => 'Privilege forbidden']); + if (empty($args['id']) || !Validator::intVal()->validate($args['id'])) { + return $response->withStatus(400)->withJson(['errors' => 'Route id is not an integer']); } - if (!Validator::intVal()->notEmpty()->validate($args['id'])) { - return $response->withStatus(400)->withJson(['errors' => 'Route id is not an integer']); + if (!UserController::hasRightByUserId(['activeUserId' => $GLOBALS['id'], 'targetUserId' => $args['id']])) { + return $response->withStatus(403)->withJson(['errors' => 'Privilege forbidden']); } $connection = ConfigurationModel::getConnection(); @@ -760,6 +825,17 @@ class UserController return $response->withStatus(204); } + public function getManageableGroupsOfCurrentUser(Request $request, Response $response) + { + if (!PrivilegeController::hasPrivilege(['userId' => $GLOBALS['id'], 'privilege' => 'manage_users'])) { + return $response->withStatus(403)->withJson(['errors' => 'Privilege forbidden']); + } + + $manageableGroups = UserController::getManageableGroups(['userId' => $GLOBALS['id']]); + + return $response->withJson(['groups' => $manageableGroups]); + } + public static function getUserInformationsById(array $args) { ValidatorModel::notEmpty($args, ['id']); @@ -802,4 +878,75 @@ class UserController return $user; } + + public static function getManageableGroups(array $args) + { + ValidatorModel::notEmpty($args, ['userId']); + ValidatorModel::intVal($args, ['userId']); + + if (PrivilegeController::hasPrivilege(['userId' => $args['userId'], 'privilege' => 'manage_groups'])) { + $groups = GroupModel::get(['select' => ['id']]); + $groups = array_column($groups, 'id'); + return GroupModel::get(); + } + + $groups = UserGroupModel::get(['select' => ['group_id'], 'where' => ['user_id = ?'], 'data' => [$args['userId']]]); + + $manageableGroups = []; + foreach ($groups as $group) { + $privilege = GroupPrivilegeModel::getPrivileges([ + 'select' => ['parameters'], + 'where' => ['group_id = ?', 'privilege = ?'], + 'data' => [$group['group_id'], 'manage_users'] + ]); + $parameters = empty($privilege[0]['parameters']) ? [] : json_decode($privilege[0]['parameters'], true); + $currentGroups = $parameters['authorized'] ?? []; + $manageableGroups = array_merge($manageableGroups, $currentGroups); + } + $manageableGroups = array_unique($manageableGroups); + + if (empty($manageableGroups)) { + return []; + } + + $manageableGroups = GroupModel::get([ + 'where' => ['id in (?)'], + 'data' => [$manageableGroups] + ]); + + return $manageableGroups; + } + + /** + * hasRightByUserId returns whether activeUser has right on targetUser, + * with an optional focus targetGroup + * + * @return bool + */ + public static function hasRightByUserId(array $args) + { + ValidatorModel::notEmpty($args, ['activeUserId', 'targetUserId']); + ValidatorModel::intVal($args, ['activeUserId', 'targetUserId', 'targetGroupId']); + + $activeUserManageableGroups = array_column(UserController::getManageableGroups(['userId' => $args['activeUserId']]), 'id'); + if (empty($activeUserManageableGroups)) { + return false; + } + + $targetUserGroups = array_column(UserGroupModel::get([ + 'select' => ['group_id'], + 'where' => ['user_id = ?'], + 'data' => [$args['targetUserId']] + ]), 'group_id'); + if (empty($targetUserGroups)) { + return true; + } + + $groupsIntersection = array_intersect($targetUserGroups, $activeUserManageableGroups); + if (empty($args['targetGroupId'])) { + return $args['activeUserId'] == $args['targetUserId'] || !empty($groupsIntersection); + } + + return in_array($args['targetGroupId'], $groupsIntersection); + } } diff --git a/src/app/user/models/UserGroupModel.php b/src/app/user/models/UserGroupModel.php index 9fefc0be3121cf865c526228bf43102fd2bd862f..b0a9c87b925c5b872cd0dc97db75227525f332b4 100755 --- a/src/app/user/models/UserGroupModel.php +++ b/src/app/user/models/UserGroupModel.php @@ -117,4 +117,30 @@ class UserGroupModel return true; } + + public static function setUserGroups(array $args) { + ValidatorModel::notEmpty($args, ['userId']); + ValidatorModel::intVal($args, ['userId']); + ValidatorModel::arrayType($args, ['groups']); + + $currentGroups = UserGroupModel::get([ + 'select' => ['group_id'], + 'where' => ['user_id = ?'], + 'data' => [$args['userId']] + ]); + $currentGroups = array_column($currentGroups, 'group_id'); + + foreach ($currentGroups as $key => $currentGroup) { + if (!in_array($currentGroup, $args['groups'])) { + UserGroupModel::removeUser(['userId' => $args['userId'], 'groupId' => $currentGroup]); + unset($currentGroups[$key]); + } + } + + foreach ($args['groups'] as $newGroup) { + if (!in_array($newGroup, $currentGroups)) { + UserGroupModel::addUser(['userId' => $args['userId'], 'groupId' => $newGroup]); + } + } + } } diff --git a/src/app/user/models/UserModel.php b/src/app/user/models/UserModel.php index 42ca5c83f34afc8a855c464f5c1b026d688be060..8cef89e10253b8a781bf7a5f12f832544eb70b92 100755 --- a/src/app/user/models/UserModel.php +++ b/src/app/user/models/UserModel.php @@ -14,6 +14,7 @@ namespace User\models; +use PHPUnit\Util\Xml\Validator; use SrcCore\models\AuthenticationModel; use SrcCore\models\DatabaseModel; use SrcCore\models\ValidatorModel; @@ -81,6 +82,7 @@ class UserModel { ValidatorModel::notEmpty($args, ['login', 'email', 'firstname', 'lastname', 'picture']); ValidatorModel::stringType($args, ['login', 'email', 'firstname', 'lastname', 'phone', 'picture', 'mode', 'signatureModes', 'x509_fingerprint']); + ValidatorModel::arrayType($args, ['groups']); if (empty($args['password'])) { $args['password'] = AuthenticationModel::generatePassword(); @@ -101,10 +103,12 @@ class UserModel 'picture' => $args['picture'], 'password_modification_date' => 'CURRENT_TIMESTAMP', 'signature_modes' => $args['signatureModes'], - 'x509_fingerprint' => $args['x509_fingerprint'], + 'x509_fingerprint' => $args['x509_fingerprint'] ] ]); + UserGroupModel::setUserGroups(['userId' => $nextSequenceId, 'groups' => $args['groups']]); + return $nextSequenceId; } diff --git a/src/core/controllers/AuthenticationController.php b/src/core/controllers/AuthenticationController.php index 32dde88a04da6ff6514252c465a40d18c0c5adbe..fed906f671102396ab98c4c002c01778e69e7980 100755 --- a/src/core/controllers/AuthenticationController.php +++ b/src/core/controllers/AuthenticationController.php @@ -105,7 +105,7 @@ class AuthenticationController $connection = ConfigurationModel::getConnection(); if (in_array($connection, ['default', 'ldap'])) { - if (!Validator::stringType()->notEmpty()->validate($body['login']) || !Validator::stringType()->notEmpty()->validate($body['password'])) { + if (!array_key_exists('login', $body) || !array_key_exists('password', $body) || !Validator::stringType()->notEmpty()->validate($body['login']) || !Validator::stringType()->notEmpty()->validate($body['password'])) { return $response->withStatus(400)->withJson(['errors' => 'Bad Request']); } } diff --git a/src/core/models/DatabasePDO.php b/src/core/models/DatabasePDO.php index ba426ebf7c06933a36d764b6ca5872e84a616bd4..e47f6c31a80b3ac35ea81f79bf5eeff31f055ebe 100755 --- a/src/core/models/DatabasePDO.php +++ b/src/core/models/DatabasePDO.php @@ -69,7 +69,7 @@ class DatabasePDO \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, \PDO::ATTR_CASE => \PDO::CASE_LOWER ]; - + try { self::$pdo = new \PDO($dsn, $user, $password, $options); break; @@ -79,7 +79,7 @@ class DatabasePDO self::$pdo = new \PDO($dsn, $user, $password, $options); break; } catch (\PDOException $PDOException) { - if (!empty($loadedXml->database[$key + 1])) { + if (!empty($loadedXml->{$key.'1'})) { continue; } else { throw new \Exception($PDOException->getMessage()); diff --git a/src/core/models/PasswordModel.php b/src/core/models/PasswordModel.php index 2ddb2fae0a3e11f0a603c5555daccf3515514158..c2e2ee351e48d5d24c250afba3b5f97385256557 100755 --- a/src/core/models/PasswordModel.php +++ b/src/core/models/PasswordModel.php @@ -23,8 +23,8 @@ class PasswordModel $aRules = DatabaseModel::select([ 'select' => empty($aArgs['select']) ? ['*'] : $aArgs['select'], 'table' => ['password_rules'], - 'where' => $aArgs['where'], - 'data' => $aArgs['data'], + 'where' => $aArgs['where'] ?? [], + 'data' => $aArgs['data'] ?? [], ]); return $aRules; diff --git a/src/frontend/app/administration/group/group.component.html b/src/frontend/app/administration/group/group.component.html index 5627cd3c1f1adcba9365b9670506f0cb1fad60b4..2d2fd6526dd72e574b4a8e27bc1ff80d2aa3e024 100644 --- a/src/frontend/app/administration/group/group.component.html +++ b/src/frontend/app/administration/group/group.component.html @@ -22,54 +22,68 @@ <ion-toggle slot="start" [name]="privilege.id" color="primary" [checked]="privilege.checked" [(ngModel)]="privilege.checked" (click)="togglePrivilege(privilege, true)"></ion-toggle> <ion-label (click)="togglePrivilege(privilege, false)">{{'lang.' + privilege.id + 'Admin' | translate}}</ion-label> + <ion-button *ngIf="canManage(privilege)" style="margin-right: 43%; margin-bottom: 10px;" fill="clear" shape="round" + (click)="$event.stopPropagation(); openGroupList()" + [title]="'lang.groupsToManage' | translate"> + <ion-icon name="people" [color]="getChecked() > 0 ? 'secondary' : 'primary'" style="font-size: 20px;"></ion-icon> + </ion-button> </ion-item> </div> - <ion-item lines="none" *ngIf="!creationMode"> - <ion-label color="secondary">{{'lang.linkedUsers' | translate}} :</ion-label> - </ion-item> - <ion-searchbar [placeholder]="'lang.filter' | translate" style="margin-left: 4x; display: flex; width: 50%;" - (ionChange)="applyFilter($event.detail.value)"> - </ion-searchbar> - <ion-card *ngIf="!creationMode" style="height: 400px; overflow-y: auto;"> - <ion-list> - <ion-item style="position: sticky;top:0px;z-index:1;"> - <ng-container style="display: flex;align-items: center;justify-content: center;width: 100%;background: white;"> - <ion-label color="primary" matSort [matSortActive]="displayedColumns[1]" matSortDirection='asc' - style="display: flex;font-size: 12px;align-items: center;" (matSortChange)="sortData($event)"> - <ng-container *ngFor="let col of displayedColumns"> - <div [mat-sort-header]="col" disableClear style="flex: 1" *ngIf="col!=='actions'"> - {{'lang.' + col | translate}} - </div> - </ng-container> - <div style="flex: 1;text-align: right;" *ngIf="displayedColumns.indexOf('actions') > -1"> - <ion-button slot="end" color="primary" fille="outline" shape="round" - (click)="openUserList()"> - {{'lang.add' | translate}} - </ion-button> - </div> - </ion-label> - </ng-container> - <ion-button slot="end" fill="clear" shape="round" disabled> - <ion-icon></ion-icon> - </ion-button> - </ion-item> - <ion-virtual-scroll [items]="sortedData" approxItemHeight="50px"> - <ion-item *virtualItem="let element" style="display: flex;"> - <ion-label style="display: flex;cursor: pointer;" - routerLink="/administration/users/{{element.id}}"> - <div style="flex: 1" *ngFor="let col of displayedColumns"> - {{element[col]}} + <ng-container *ngIf="!creationMode"> + <ion-item lines="none"> + <ion-label color="secondary">{{'lang.linkedUsers' | translate}} :</ion-label> + </ion-item> + <ion-searchbar [placeholder]="'lang.filter' | translate" style="margin-left: 4x; display: flex; width: 50%;" + (ionChange)="applyFilter($event.detail.value)"> + </ion-searchbar> + <ion-content style="height: 400px; overflow-y: auto;"> + <ion-list> + <ion-item style="position: sticky;top:0px;z-index:1;"> + <ng-container style="display: flex;align-items: center;justify-content: center;width: 100%;background: white;"> + <ion-label color="primary" matSort [matSortActive]="displayedColumns[1]" matSortDirection='asc' + style="display: flex;font-size: 12px;align-items: center;" (matSortChange)="sortData($event)"> + <ng-container *ngFor="let col of displayedColumns"> + <div [mat-sort-header]="col" disableClear style="flex: 1" *ngIf="col!=='actions'"> + {{'lang.' + col | translate}} + </div> + </ng-container> + <div style="flex: 1;text-align: right;" *ngIf="displayedColumns.indexOf('actions') > -1"> + <ion-button slot="end" color="primary" fille="outline" shape="round" + (click)="openUserList()"> + {{'lang.add' | translate}} + </ion-button> </div> </ion-label> - <ion-button slot="end" fill="clear" shape="round" - (click)="$event.stopPropagation();unlinkUser(element)" - title="{{'lang.unlinkUser' | translate}}"> - <ion-icon color="danger" slot="icon-only" name="close-outline"></ion-icon> + </ng-container> + <ion-button slot="end" fill="clear" shape="round" disabled> + <ion-icon></ion-icon> </ion-button> </ion-item> - </ion-virtual-scroll> - </ion-list> - </ion-card> + <ion-virtual-scroll [items]="sortedData" approxItemHeight="50px"> + <ion-item *virtualItem="let element" style="display: flex;"> + <ion-label style="display: flex;cursor: pointer;" + routerLink="/administration/users/{{element.id}}"> + <div style="flex: 1" *ngFor="let col of displayedColumns"> + {{element[col]}} + </div> + </ion-label> + <ion-button slot="end" fill="clear" shape="round" + (click)="$event.stopPropagation();unlinkUser(element)" + title="{{'lang.unlinkUser' | translate}}"> + <ion-icon color="danger" slot="icon-only" name="close-outline"></ion-icon> + </ion-button> + </ion-item> + </ion-virtual-scroll> + <ion-infinite-scroll threshold="100px" (ionInfinite)="loadData($event)" *ngIf="group.users.length > 7"> + <ion-infinite-scroll-content loadingSpinner="bubbles" [loadingText]="'lang.loadingMoreData' | translate"> + </ion-infinite-scroll-content> + </ion-infinite-scroll> + </ion-list> + <ion-item lines="none" *ngIf="group.users.length === 0" style="text-align: center; font-size: 20px; color: gray; margin-top: 5px;"> + <ion-label>{{ 'lang.emptyGroupUsers' | translate }}</ion-label> + </ion-item> + </ion-content> + </ng-container> <ion-item text-center lines="none" style="position: sticky;bottom:0px;z-index:1;"> <div style="display: flex;align-items: center;justify-content: center;width: 100%;background: white;"> <ion-button type="submit" shape="round" size="large" fill="outline" color="primary" diff --git a/src/frontend/app/administration/group/group.component.ts b/src/frontend/app/administration/group/group.component.ts index d77a98b4715e849b9667bf15322c6b59a834fb3b..4f7916496dfd136f71a46c28a9f61d1ab93850de 100644 --- a/src/frontend/app/administration/group/group.component.ts +++ b/src/frontend/app/administration/group/group.component.ts @@ -13,6 +13,8 @@ import { AlertController, ModalController, PopoverController } from '@ionic/angu import { UsersComponent } from './list/users.component'; import { of } from 'rxjs'; import { LatinisePipe } from 'ngx-pipes'; +import { GroupModalComponent } from './modal/group-modal.component'; +import { FunctionsService } from '../../service/functions.service'; export interface Group { @@ -41,6 +43,10 @@ export class GroupComponent implements OnInit { displayedColumns: string[]; usersList: any[]; sortedData: any[]; + groups: any[] = []; + allGroups: any[] = []; + + dataChanged: boolean = false; constructor( public http: HttpClient, @@ -54,7 +60,8 @@ export class GroupComponent implements OnInit { public popoverController: PopoverController, public modalController: ModalController, public alertController: AlertController, - private latinisePipe: LatinisePipe + private latinisePipe: LatinisePipe, + public functions: FunctionsService ) { this.displayedColumns = ['firstname', 'lastname', 'actions']; this.group = { @@ -77,7 +84,7 @@ export class GroupComponent implements OnInit { this.creationMode = false; this.usersList = []; - this.http.get('../rest/groups/' + params['id']) + this.http.get(`../rest/groups/${params['id']}`) .pipe( map((data: any) => data.group), finalize(() => { @@ -88,6 +95,9 @@ export class GroupComponent implements OnInit { this.groupClone = JSON.parse(JSON.stringify(this.group)); this.title = this.group.label; this.updateDataTable(); + if (this.group.privileges.find((privilige: any) => privilige.id === 'manage_users') !== undefined) { + this.getGroups(); + } }), catchError((err: any) => { this.notificationService.handleErrors(err); @@ -130,6 +140,64 @@ export class GroupComponent implements OnInit { } } + async openGroupList() { + await this.getPrivilegeParameters(); + const modal = await this.modalController.create({ + component: GroupModalComponent, + componentProps: { + groups: this.groups, + editUser: false + } + }); + await modal.present(); + const { data } = await modal.onWillDismiss(); + if (data !== undefined) { + this.groups = data; + const privilege: any = this.group.privileges.find((item: any) => item.id === 'manage_users'); + this.dataChanged = true; + this.updatePrivilege(privilege); + } + } + + getPrivilegeParameters() { + return new Promise((resolve) => { + this.http.get('../rest/groups/' + this.group.id).pipe( + tap((data: any) => { + const manageUsers: any = data.group.privileges.find((item: any) => item.id === 'manage_users'); + if (!this.functions.empty(manageUsers)) { + this.allGroups.forEach((element: any, index: number) => { + if (this.groups.find((item: any) => item.id === element.id) === undefined) { + let checked: boolean; + if (!this.functions.empty(manageUsers.parameters.authorized)) { + checked = manageUsers.parameters.authorized.indexOf(element.id) > -1; + } else { + checked = true; + } + this.groups.push( + { + id: element.id, + label: element.label, + checked: checked + } + ); + } + }); + this.groups = [...new Set(this.groups)]; + } + resolve(true); + }), + catchError((err: any) => { + this.notificationService.handleErrors(err); + return of(false); + }) + ).subscribe(); + }); + } + + getChecked() { + return this.groups.filter((item: any) => item.checked).length; + } + canValidate() { if (this.group.label === this.groupClone.label) { return false; @@ -301,6 +369,13 @@ export class GroupComponent implements OnInit { }); await alert.present(); } else { + if (privilege.id === 'manage_users') { + if (!privilege.checked) { + this.getGroups(); + } else { + this.groups = []; + } + } if (!toggle) { privilege.checked = !privilege.checked; } @@ -310,8 +385,37 @@ export class GroupComponent implements OnInit { } } + getGroups() { + this.http.get('../rest/groups').pipe( + tap((data: any) => { + this.allGroups = data.groups; + }), + catchError((err: any) => { + this.notificationService.handleErrors(err); + return of(false); + }) + ).subscribe(); + } + updatePrivilege(privilege: any) { - this.http.put('../rest/groups/' + this.group.id + '/privilege/' + privilege.id, { checked: privilege.checked }) + if (!this.dataChanged && this.groups.length === 0 && privilege.checked) { + this.groups = this.allGroups.map((group: any) => ({ + id: group.id, + label: group.label, + checked: true + })); + } + + const objTosend: any = { + checked: privilege.checked, + parameters: { + authorized: this.groups.filter((element: any) => element.checked).map((item: any) => item.id) + } + }; + if (privilege.id !== 'manage_users') { + delete objTosend.parameters.authorized; + } + this.http.put('../rest/groups/' + this.group.id + '/privilege/' + privilege.id, objTosend) .pipe( tap(() => { this.notificationService.success('lang.privilegeUpdated'); @@ -354,6 +458,15 @@ export class GroupComponent implements OnInit { return state; }); } + + canManage(privilege: any) { + return privilege.id === 'manage_users' && privilege.checked; + } + + loadData(event: any) { + event.target.complete(); + event.target.disabled = true; + } } diff --git a/src/frontend/app/administration/group/list/users.component.html b/src/frontend/app/administration/group/list/users.component.html index 2af53a9763a8c074cdb47950f5e74a4dbad21a1d..7d5ba32e6c5388a536e983d87cc83ac976aaf380 100644 --- a/src/frontend/app/administration/group/list/users.component.html +++ b/src/frontend/app/administration/group/list/users.component.html @@ -4,7 +4,7 @@ </ion-toolbar> </ion-header> <ion-content> - <ion-list> + <ion-list *ngIf="usersList.length > 0"> <ion-virtual-scroll [items]="usersList" approxItemHeight="50px" style="height: 450px;"> <ion-item button *virtualItem="let element" (click)="selectUser(element)"> <ion-label> @@ -13,4 +13,7 @@ </ion-item> </ion-virtual-scroll> </ion-list> + <ion-item lines="none" *ngIf="usersList.length === 0" style="text-align: center; font-size: 20px; color: gray; margin-top: 35%;"> + <ion-label>{{'lang.emptyUsers' | translate}}</ion-label> + </ion-item> </ion-content> \ No newline at end of file diff --git a/src/frontend/app/administration/group/modal/group-modal.component.html b/src/frontend/app/administration/group/modal/group-modal.component.html new file mode 100644 index 0000000000000000000000000000000000000000..2949ef7b1fc2228f468fa2ca964878fc1a4eb3d3 --- /dev/null +++ b/src/frontend/app/administration/group/modal/group-modal.component.html @@ -0,0 +1,26 @@ +<ion-header [translucent]="true"> + <ion-toolbar color="primary"> + <ion-title>{{(editUser ? 'lang.groups' : 'lang.groupsToManage') | translate}}</ion-title> + </ion-toolbar> +</ion-header> +<ion-content> + <ion-list> + <ion-virtual-scroll [items]="groups" approxItemHeight="50px" style="height: 450px;"> + <ion-item *virtualItem="let element"> + <ion-label> + {{element.label}} + </ion-label> + <ion-checkbox slot="end" [value]="element.id" [checked]="element.checked" (ionChange)="element.checked = !element.checked"></ion-checkbox> + </ion-item> + </ion-virtual-scroll> + </ion-list> +</ion-content> +<ion-footer class="ion-no-border"> + <ion-toolbar> + <ion-buttons class="ion-justify-content-center"> + <ion-button type="submit" color="primary" (click)="saveChanges()"> + <ion-label>{{'lang.save' | translate}}</ion-label> + </ion-button> + </ion-buttons> + </ion-toolbar> +</ion-footer> \ No newline at end of file diff --git a/src/frontend/app/administration/group/modal/group-modal.component.scss b/src/frontend/app/administration/group/modal/group-modal.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/frontend/app/administration/group/modal/group-modal.component.ts b/src/frontend/app/administration/group/modal/group-modal.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..162ba6b945e61d84be29b406417bc46ffe0c275a --- /dev/null +++ b/src/frontend/app/administration/group/modal/group-modal.component.ts @@ -0,0 +1,24 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { ModalController } from '@ionic/angular'; + +@Component({ + templateUrl: 'group-modal.component.html', + styleUrls: ['group-modal.component.scss'], +}) + +export class GroupModalComponent implements OnInit { + + @Input() groups: any; + @Input() editUser: boolean; + + constructor( + public modalController: ModalController, + ) {} + + async ngOnInit() { + } + + saveChanges() { + this.modalController.dismiss(this.groups); + } +} diff --git a/src/frontend/app/administration/user/user.component.html b/src/frontend/app/administration/user/user.component.html index 2ca44e3acb0665f534014f7268e98848e053856a..f413a7788d9239e999bab64bbf53e30f4dca4d2b 100644 --- a/src/frontend/app/administration/user/user.component.html +++ b/src/frontend/app/administration/user/user.component.html @@ -18,7 +18,7 @@ <ion-label>{{'lang.informations' | translate}}</ion-label> <ion-icon name="information-circle"></ion-icon> </ion-segment-button> - <ion-segment-button [disabled]="creationMode" value="groups"> + <ion-segment-button *ngIf="!creationMode" value="groups"> <ion-label>{{'lang.manage_groups' | translate}}</ion-label> <ion-icon name="people-sharp"></ion-icon> </ion-segment-button> @@ -118,14 +118,27 @@ <ng-container *ngIf="currentTool === 'groups'"> <ion-content> + <ion-item lines="none" style="position: sticky;top:0px;z-index:1;"> + <ng-container style="display: flex;align-items: center;justify-content: center;width: 100%;background: white;"> + <div style="flex: 1;text-align: right;"> + <ion-button slot="end" color="primary" fille="outline" shape="round" [disabled]="userGroupsClone.length === 0" (click)="openGroupsList()"> + {{'lang.add' | translate}} + </ion-button> + </div> + </ng-container> + </ion-item> <ion-list *ngIf="user.groups.length > 0"> <ion-item *ngFor="let group of user.groups"> <ion-label>{{group.label}}</ion-label> + <ion-button slot="end" fill="clear" shape="round" + (click)="unlinkGroup(group);" [title]="'lang.unlinkGroup' | translate" [disabled]="!isManageableGroup(group.id)"> + <ion-icon color="danger" slot="icon-only" name="close-outline"></ion-icon> + </ion-button> </ion-item> </ion-list> <ion-list class="no-result" *ngIf="user.groups.length === 0"> <ion-item lines="none"> - <ion-label class="no-result-label" color="medium">{{'lang.noAssociatedGroup' | translate}}</ion-label> + <ion-label class="no-result-label" color="medium">{{(userGroupsClone.length === 0 ? 'lang.emptyGroups' : 'lang.noAssociatedGroup') | translate}}</ion-label> </ion-item> </ion-list> </ion-content> diff --git a/src/frontend/app/administration/user/user.component.ts b/src/frontend/app/administration/user/user.component.ts index 93bf2955f252822d93872fc1f0ce1e07927ab235..5c08ccc3a1e50ad4d1d55de2be63cc4da1cb2667 100644 --- a/src/frontend/app/administration/user/user.component.ts +++ b/src/frontend/app/administration/user/user.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, ViewChild } from '@angular/core'; import { SignaturesContentService } from '../../service/signatures.service'; import { NotificationService } from '../../service/notification.service'; import { HttpClient, HttpHeaders } from '@angular/common/http'; @@ -9,7 +9,8 @@ import { ConfirmComponent } from '../../plugins/confirm.component'; import { TranslateService } from '@ngx-translate/core'; import { AuthService } from '../../service/auth.service'; import { of } from 'rxjs'; -import { AlertController } from '@ionic/angular'; +import { AlertController, ModalController } from '@ionic/angular'; +import { GroupModalComponent } from '../group/modal/group-modal.component'; export interface User { @@ -77,6 +78,10 @@ export class UserComponent implements OnInit { errorMsg: '' }; + hasGroup: boolean = false; + userGroupsClone: any; + manageableGroups: any; + constructor( public http: HttpClient, private translate: TranslateService, @@ -86,12 +91,13 @@ export class UserComponent implements OnInit { public notificationService: NotificationService, public dialog: MatDialog, public authService: AuthService, - public alertController: AlertController + public alertController: AlertController, + public modalController: ModalController, ) { } - ngOnInit(): void { - this.route.params.subscribe((params: any) => { + async ngOnInit(): Promise<void> { + this.route.params.subscribe(async (params: any) => { if (params['id'] === undefined) { this.creationMode = true; this.title = this.translate.instant('lang.userCreation'); @@ -108,29 +114,56 @@ export class UserComponent implements OnInit { canSendActivationNotification: false }; this.loading = false; + await this.isCreationMode(); } else { - this.creationMode = false; - this.http.get('../rest/users/' + params['id']) - .pipe( - map((data: any) => data.user), - finalize(() => this.loading = false) - ) - .subscribe({ - next: data => { - this.user = data; - this.userClone = JSON.parse(JSON.stringify(this.user)); - this.title = this.user.firstname + ' ' + this.user.lastname; - if (this.user.isRest) { - this.getPassRules({ detail: { - checked: true - } }); - } - }, - }); + this.isUpdateMode(params['id']); + this.loading = false; } }); } + async isCreationMode() { + this.creationMode = true; + this.title = this.translate.instant('lang.userCreation'); + this.user = { + id: '', + firstname: '', + lastname: '', + login: '', + email: '', + phone: '', + picture: '', + signatureModes: ['stamp'], + isRest: false, + canSendActivationNotification: false + }; + await this.getManageableGroups(); + this.loading = false; + } + + isUpdateMode(id: any) { + this.creationMode = false; + this.http.get(`../rest/users/${id}`) + .pipe( + map((data: any) => data.user) + ) + .subscribe({ + next: async data => { + this.user = data; + this.hasGroup = data.groups.length > 0; + this.userGroupsClone = JSON.parse(JSON.stringify(data.groups)); + await this.getManageableGroups(); + this.userClone = JSON.parse(JSON.stringify(this.user)); + this.title = this.user.firstname + ' ' + this.user.lastname; + if (this.user.isRest) { + this.getPassRules({ detail: { + checked: true + } }); + } + }, + }); + } + canValidate() { if (this.user.isRest && this.passwordRest.newPassword !== '' && (this.handlePassword.error || this.passwordRest.passwordConfirmation !== this.passwordRest.newPassword)) { return false; @@ -151,7 +184,8 @@ export class UserComponent implements OnInit { modifyUser() { this.loading = true; - this.http.put('../rest/users/' + this.user.id, this.user) + const objToSend = {...this.user, groups: this.user['groups'].map((group: any) => group.id)}; + this.http.put('../rest/users/' + this.user.id, objToSend) .pipe( finalize(() => this.loading = false), tap(() => { @@ -161,7 +195,7 @@ export class UserComponent implements OnInit { if (this.passwordRest.newPassword !== '') { this.updateRestUser(); } - this.router.navigate(['/administration/users']); + this.router.navigate(['/administration/users/' + this.user.id]); this.notificationService.success('lang.userUpdated'); }), catchError((err: any) => { @@ -195,7 +229,7 @@ export class UserComponent implements OnInit { this.user.id = data.id; this.updateRestUser(); } - this.router.navigate(['/administration/users']); + this.router.navigate([`/administration/users/${data.id}`]); this.notificationService.success('lang.userAdded'); }), catchError((err: any) => { @@ -361,4 +395,101 @@ export class UserComponent implements OnInit { await alert.present(); } + + getManageableGroups() { + return new Promise((resolve) => { + this.http.get('../rest/manageableGroups').pipe( + tap((data: any) => { + this.manageableGroups = JSON.parse(JSON.stringify(data.groups)); + if (this.hasGroup) { + const groupIds: number[] = this.userGroupsClone.map((group: any) => group.id); + this.userGroupsClone = JSON.parse(JSON.stringify(data.groups.filter((group: any) => groupIds.indexOf(group.id) === -1))); + } else { + this.userGroupsClone = JSON.parse(JSON.stringify(data.groups)); + } + resolve(true); + }), + finalize(() => this.loading = false), + catchError((err: any) => { + this.notificationService.handleErrors(err); + return of(false); + }) + ).subscribe(); + }); + } + + isManageableGroup(groupId: any) { + return this.manageableGroups.find((userGroup: any) => userGroup.id === groupId) !== undefined; + } + + async unlinkGroup(group: any) { + const cannotManage: boolean = this.user['groups'].length === 1 && this.user.id === this.authService.user.id && this.authService.user.administrativePrivileges.find((privilege: any) => privilege.id === 'manage_users') !== undefined; + const alert = await this.alertController.create({ + header: this.translate.instant('lang.confirmMsg'), + message: cannotManage ? this.translate.instant('lang.groupWarnMsg') : '', + buttons: [ + { + text: this.translate.instant('lang.no'), + role: 'cancel', + cssClass: 'secondary', + handler: () => { } + }, + { + text: this.translate.instant('lang.yes'), + handler: () => { + this.http.delete(`../rest/groups/${group.id}/users/${this.user.id}`, {}) + .pipe( + tap(() => { + this.isUpdateMode(this.user.id); + this.userGroupsClone.push(group); + }), + finalize(() => this.loading = false) + ) + .subscribe({ + next: data => { + const indexToDelete = this.user['groups'].findIndex((item: any) => item.id === group.id); + this.user['groups'].splice(indexToDelete, 1); + this.notificationService.success('lang.groupDeleted'); + this.authService.updateUserInfoWithTokenRefresh(); + this.http.get(`../rest/users/${this.user.id}`).pipe( + tap(() => {}), + catchError((err: any) => { + if (err.error.errors === 'User out of perimeter') { + this.router.navigate(['/administration/users']); + } + return of(false); + }) + ).subscribe(); + }, + error: err => { + this.notificationService.handleErrors(err); + } + }); + } + } + ] + }); + await alert.present(); + } + + async openGroupsList() { + const modal = await this.modalController.create({ + component: GroupModalComponent, + componentProps: { + groups: this.userGroupsClone, + editUser: true + } + }); + await modal.present(); + const { data } = await modal.onWillDismiss(); + if (data !== undefined) { + this.user['groups'] = this.user['groups'].concat(data.filter((group: any) => group.checked).map((item: any) => ({ + id: item.id, + label: item.label + }))); + const groupIds: number[] = this.user['groups'].map((group: any) => group.id); + this.userGroupsClone = this.userGroupsClone.filter((group: any) => groupIds.indexOf(group.id) === -1); + this.modifyUser(); + } + } } diff --git a/src/frontend/app/app.module.ts b/src/frontend/app/app.module.ts index 01d6147279a0e188e0fd1ea80b0f1da33ca0c828..de52beba4beb533e55ca37b39ab416411a43d177 100755 --- a/src/frontend/app/app.module.ts +++ b/src/frontend/app/app.module.ts @@ -85,6 +85,7 @@ import { HistoryListComponent } from './administration/history/history-list.comp import { OtpListComponent } from './administration/otp/otp-list.component'; import { OtpComponent } from './administration/otp/otp.component'; import { CustomizationComponent } from './administration/customization/customization.component'; +import { GroupModalComponent } from './administration/group/modal/group-modal.component'; // SERVICES @@ -172,7 +173,8 @@ registerLocaleData(localeFr, 'fr-FR'); GridButtonComponent, NotificationsListComponent, MessageBoxComponent, - AboutUsComponent + AboutUsComponent, + GroupModalComponent ], imports: [ FormsModule, diff --git a/src/frontend/app/document/document-list/document-list.component.ts b/src/frontend/app/document/document-list/document-list.component.ts index ab11d5a3b09ccc908af835bc74be82cce1b4b02d..ba25663d8e30f36924afe7cef0abc4d3c47f3ae9 100644 --- a/src/frontend/app/document/document-list/document-list.component.ts +++ b/src/frontend/app/document/document-list/document-list.component.ts @@ -1,15 +1,17 @@ -import { Component, Input, OnInit, Output, EventEmitter, ViewChild, AfterViewInit } from '@angular/core'; +import { Component, Input, OnInit, Output, EventEmitter, ViewChild, AfterViewInit, OnDestroy } from '@angular/core'; import { SignaturesContentService } from '../../service/signatures.service'; import { HttpClient } from '@angular/common/http'; import { DomSanitizer } from '@angular/platform-browser'; import { IonSlides, MenuController } from '@ionic/angular'; +import { Subscription } from 'rxjs'; +import { ActionsService } from '../../service/actions.service'; @Component({ selector: 'app-document-list', templateUrl: 'document-list.component.html', styleUrls: ['document-list.component.scss'], }) -export class DocumentListComponent implements OnInit, AfterViewInit { +export class DocumentListComponent implements OnInit, AfterViewInit, OnDestroy { @Input() docList: any; @Input() currentDocId: any; @@ -18,6 +20,8 @@ export class DocumentListComponent implements OnInit, AfterViewInit { @ViewChild('slides', { static: false }) slides: IonSlides; + subscription: Subscription; + loading: boolean = true; scrolling: boolean = false; @@ -30,9 +34,16 @@ export class DocumentListComponent implements OnInit, AfterViewInit { constructor( public http: HttpClient, public signaturesService: SignaturesContentService, + private menu: MenuController, + private actionsService: ActionsService, private sanitizer: DomSanitizer, - private menu: MenuController - ) { } + ) { + this.subscription = this.actionsService.catchEvent().subscribe((event: any) => { + if (event === 'scrollToTop') { + this.slides.slideTo(0, 0); + } + }); + } ngOnInit(): void { this.docList.forEach((element: any, index: number) => { @@ -49,6 +60,10 @@ export class DocumentListComponent implements OnInit, AfterViewInit { this.loading = false; } + ngOnDestroy(): void { + this.subscription.unsubscribe(); + } + loadDoc(id: string) { this.triggerEvent.emit(id); this.menu.close('right-menu'); diff --git a/src/frontend/app/document/document.component.ts b/src/frontend/app/document/document.component.ts index f5732e14764dd8cc642586a3f48ade5b39c41442..ffbda0c365058190e2c5b137684a86ecd4f63c4b 100755 --- a/src/frontend/app/document/document.component.ts +++ b/src/frontend/app/document/document.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, ViewChild, TemplateRef, ViewContainerRef, Injectable } from '@angular/core'; +import { Component, OnInit, ViewChild, TemplateRef, ViewContainerRef, Injectable, OnDestroy } from '@angular/core'; import { SignaturesContentService } from '../service/signatures.service'; import { DomSanitizer } from '@angular/platform-browser'; import { MatBottomSheet, MatBottomSheetConfig } from '@angular/material/bottom-sheet'; @@ -18,7 +18,7 @@ import { LocalStorageService } from '../service/local-storage.service'; import { ActionSheetController, AlertController, LoadingController, MenuController, ModalController, NavController } from '@ionic/angular'; import { NgxExtendedPdfViewerService } from 'ngx-extended-pdf-viewer'; import { catchError, exhaustMap, tap } from 'rxjs/operators'; -import { of } from 'rxjs'; +import { of, Subscription } from 'rxjs'; import { SignatureMethodService } from '../service/signature-method/signature-method.service'; import { FunctionsService } from '../service/functions.service'; import { ActionsService } from '../service/actions.service'; @@ -31,7 +31,7 @@ import { SuccessInfoValidBottomSheetComponent } from '../modal/success-info-vali }) -export class DocumentComponent implements OnInit { +export class DocumentComponent implements OnInit, OnDestroy { @ViewChild('mainContent') mainContent: any; @ViewChild('img') img: any; @@ -73,6 +73,8 @@ export class DocumentComponent implements OnInit { workflow: [], }; + subscription: Subscription; + signaturesContent: any = []; docList: any = []; @@ -125,6 +127,16 @@ export class DocumentComponent implements OnInit { private cookieService: CookieService, ) { this.draggable = false; + this.subscription = this.actionsService.catchEvent().subscribe((event: any) => { + if (event.id === 'gotoDocument' && event.documentIndex === 0) { + this.loadDoc(event.documentIndex); + this.actionsService.setEvent('scrollToTop'); + } + }); + } + + ngOnDestroy(): void { + this.subscription.unsubscribe(); } imageLoaded(ev: any) { diff --git a/src/frontend/app/document/visa-workflow/otps/yousign/otp-yousign.component.ts b/src/frontend/app/document/visa-workflow/otps/yousign/otp-yousign.component.ts index 107b1a2872a979921c0a4a16dc0293bfb0860677..eadc3da2b68fc8f80a0fdd5f3f2ce288fd7c4832 100644 --- a/src/frontend/app/document/visa-workflow/otps/yousign/otp-yousign.component.ts +++ b/src/frontend/app/document/visa-workflow/otps/yousign/otp-yousign.component.ts @@ -6,6 +6,7 @@ import { NgForm } from '@angular/forms'; import { catchError, tap } from 'rxjs/operators'; import { of } from 'rxjs'; import { OtpService } from '../otp.service'; +import { FunctionsService } from '../../../../service/functions.service'; @Component({ selector: 'app-otp-yousign', @@ -38,7 +39,8 @@ export class OtpYousignComponent implements OnInit { public http: HttpClient, private translate: TranslateService, public notificationService: NotificationService, - public otpService: OtpService + public otpService: OtpService, + private functions: FunctionsService ) { this.otpService.catchEvent().subscribe(async (res) => { if (res.id === 'connector') { @@ -75,7 +77,9 @@ export class OtpYousignComponent implements OnInit { } getData() { - this.formatPhone(); + if (!this.functions.empty(this.otp.phone)) { + this.formatPhone(); + } return this.otp; } diff --git a/src/frontend/app/sidebar/sidebar.component.html b/src/frontend/app/sidebar/sidebar.component.html index 29ecbe9a84fe5a128cb3193fd8706bd8dc0cf07d..5c8945f6379725935eae057c153271201f489343 100755 --- a/src/frontend/app/sidebar/sidebar.component.html +++ b/src/frontend/app/sidebar/sidebar.component.html @@ -77,7 +77,7 @@ </ion-label> </ion-item> <ion-menu-toggle auto-hide="false" *ngFor="let document of signaturesService.documentsList;let i=index"> - <ion-item class="doc-item" (click)="filterService.currentIndex = i" routerDirection="root" + <ion-item class="doc-item" (click)="filterService.currentIndex = i; goTo(document.id);" routerDirection="root" [routerLink]="['/documents/'+document.id]" detail="false" [class.selected]="router.url === '/documents/'+document.id"> <ion-icon *ngIf="document.mode == 'sign'" color="primary" slot="start" name="pencil-outline"></ion-icon> diff --git a/src/frontend/app/sidebar/sidebar.component.ts b/src/frontend/app/sidebar/sidebar.component.ts index e5d662cce750f4c5858709c47cfdd2e86146fe98..9bcbedeeb78ec9d5b3173270f521bad30e01ef27 100755 --- a/src/frontend/app/sidebar/sidebar.component.ts +++ b/src/frontend/app/sidebar/sidebar.component.ts @@ -165,4 +165,8 @@ export class SidebarComponent implements OnInit, AfterViewInit { canIndex() { return this.authService.user.appPrivileges.map((item: any) => item.id).indexOf('indexation') > -1; } + + goTo() { + this.actionsService.setEvent({id: 'gotoDocument', documentIndex: 0}); + } } diff --git a/test/unitTests/app/user/UserControllerTest.php b/test/unitTests/app/user/UserControllerTest.php index 1d573d5b63e680f81feace4edfd08aab1600e66d..6bd12b4a1bc05f96d3f7cab7e3df7a7f7ed8dde3 100755 --- a/test/unitTests/app/user/UserControllerTest.php +++ b/test/unitTests/app/user/UserControllerTest.php @@ -13,6 +13,7 @@ class UserControllerTest extends TestCase { private static $signatureId = null; private static $userId = null; + private static $userIdToDelete = null; public function testCreateUser() { @@ -22,10 +23,11 @@ class UserControllerTest extends TestCase $request = \Slim\Http\Request::createFromEnvironment($environment); $aArgs = [ - 'login' => 'emailLogin', - 'firstname' => 'Prénom', - 'lastname' => 'Nom', - 'email' => 'email@test.fr' + 'login' => 'emailLoginFingerprint', + 'firstname' => 'Prénom', + 'lastname' => 'Nom', + 'email' => 'email@test.fr', + 'phone' => '0701020304' ]; $fullRequest = \httpRequestCustom::addContentInBody($aArgs, $request); @@ -35,6 +37,23 @@ class UserControllerTest extends TestCase $this->assertIsInt($responseBody->id); self::$userId = $responseBody->id; + //with x509Fingerprint + $aArgs = [ + 'login' => 'emailLogin', + 'firstname' => 'Prénom', + 'lastname' => 'Nom', + 'email' => 'email@test.fr', + 'x509Fingerprint' => 'fingerprint', + 'isRest' => true + ]; + + $fullRequest = \httpRequestCustom::addContentInBody($aArgs, $request); + $response = $userController->create($fullRequest, new \Slim\Http\Response()); + $responseBody = json_decode((string)$response->getBody()); + + $this->assertIsInt($responseBody->id); + self::$userIdToDelete = $responseBody->id; + //Mail missing $aArgs = [ 'login' => 'failLogin', @@ -162,11 +181,27 @@ class UserControllerTest extends TestCase $this->assertSame('email@test.fr', $responseBody->user->email); $this->assertSame('Prénom', $responseBody->user->firstname); $this->assertSame('Nom', $responseBody->user->lastname); + $this->assertSame('0701020304', $responseBody->user->phone); $response = $userController->getById($request, new \Slim\Http\Response(), ['id' => -1]); + $this->assertSame(400, $response->getStatusCode()); $responseBody = json_decode((string)$response->getBody()); + $this->assertSame('User does not exist', $responseBody->errors); + } - $this->assertEmpty($responseBody->users); + public function testGetFingerprintById() + { + $previousUserId = $GLOBALS['id']; + $GLOBALS['id'] = self::$userIdToDelete; + $userController = new \User\controllers\UserController(); + + //find "userIdToDelete" fingerprint before user gets deleted + $response = $userController->getUserInformationsById(['id' => self::$userIdToDelete]); + + $this->assertNotEmpty($response); + $this->assertNotEmpty($response['x509Fingerprint']); + $this->assertSame('fingerprint', $response['x509Fingerprint']); + $GLOBALS['id'] = $previousUserId; } public function testUpdate() @@ -181,13 +216,14 @@ class UserControllerTest extends TestCase $request = \Slim\Http\Request::createFromEnvironment($environment); $aArgs = [ - 'writingMode' => 'stylus', - 'writingSize' => 2, - 'writingColor' => '#F1F1F1', - 'lang' => 'fr', - 'notifications' => [ - 'instant' => true, - 'summaries' => [], + 'writingMode' => 'stylus', + 'writingSize' => 2, + 'writingColor' => '#F1F1F1', + 'lang' => 'fr', + 'signatureScaling' => false, + 'notifications' => [ + 'instant' => true, + 'summaries' => [], ], ]; @@ -203,7 +239,8 @@ class UserControllerTest extends TestCase $aArgs = [ 'firstname' => 'Jolly', 'lastname' => 'Jumper', - 'email' => 'email@test.fr' + 'email' => 'email@test.fr', + 'phone' => '0701020304' ]; $fullRequest = \httpRequestCustom::addContentInBody($aArgs, $request); @@ -241,7 +278,7 @@ class UserControllerTest extends TestCase $aArgs = [ 'encodedSignature' => base64_encode(file_get_contents('test/unitTests/samples/signature.jpg')), 'format' => 'jpg' - ]; + ]; $fullRequest = \httpRequestCustom::addContentInBody($aArgs, $request); $response = $signatureController->create($fullRequest, new \Slim\Http\Response(), ['id' => self::$userId]); @@ -290,4 +327,30 @@ class UserControllerTest extends TestCase ]); $GLOBALS['id'] = $previousUserId; } + + public function testDelete() + { + $userController = new \User\controllers\UserController(); + + $environment = \Slim\Http\Environment::mock(['REQUEST_METHOD' => 'DELETE']); + $request = \Slim\Http\Request::createFromEnvironment($environment); + + $response = $userController->delete($request, new \Slim\Http\Response(), ['id' => self::$userIdToDelete]); + $this->assertSame(204, $response->getStatusCode()); + + $response = $userController->delete($request, new \Slim\Http\Response(), ['id' => self::$userIdToDelete]); + $responseBody = json_decode((string)$response->getBody()); + $this->assertSame(400, $response->getStatusCode()); + $this->assertSame('User does not exist', $responseBody->errors); + + $response = $userController->delete($request, new \Slim\Http\Response(), ['id' => '1']); + $responseBody = json_decode((string)$response->getBody()); + $this->assertSame(403, $response->getStatusCode()); + $this->assertSame('Privilege forbidden', $responseBody->errors); + + $response = $userController->delete($request, new \Slim\Http\Response(), ['id' => 1.1]); + $responseBody = json_decode((string)$response->getBody()); + $this->assertSame(400, $response->getStatusCode()); + $this->assertSame('Route id is not an integer', $responseBody->errors); + } }