From d6f134e1e9663ace7142920e112eb27288637980 Mon Sep 17 00:00:00 2001 From: Craig Williams Date: Tue, 1 Mar 2022 14:59:56 +0000 Subject: [PATCH] Organisation admins can now promote/demote members/admins --- assets/avsdev/js/widgets/members.js | 50 +++ locale/en_US/messages.php | 21 +- routes/organisation-members.php | 5 + .../OrganisationMembersController.php | 291 +++++++++++++++++- .../Seeds/OrganisationPermissions.php | 10 + ...nfirm-demote-organisation-member.html.twig | 17 + ...firm-promote-organisation-member.html.twig | 19 ++ .../tables/organisation-members.html.twig | 17 +- 8 files changed, 426 insertions(+), 4 deletions(-) create mode 100644 templates/modals/confirm-demote-organisation-member.html.twig create mode 100644 templates/modals/confirm-promote-organisation-member.html.twig diff --git a/assets/avsdev/js/widgets/members.js b/assets/avsdev/js/widgets/members.js index e81ae79..33a666d 100644 --- a/assets/avsdev/js/widgets/members.js +++ b/assets/avsdev/js/widgets/members.js @@ -12,6 +12,56 @@ function bindMemberButtons(el, options) { if (!options) options = {}; + // Remove user button + el.find('.js-member-promote').click(function(e) { + e.preventDefault(); + + $("body").ufModal({ + sourceUrl: site.uri.public + '/modals/organisations/o/' + $(this).data('slug') + '/members/confirm-promote', + ajaxParams: { + slug: $(this).data('slug'), + user_name: $(this).data('user_name') + }, + msgTarget: $("#alerts-page") + }); + + $("body").on('renderSuccess.ufModal', function() { + var modal = $(this).ufModal('getModal'); + var form = modal.find('.js-form'); + + form.ufForm() + .on("submitSuccess.ufForm", function() { + // Navigate or reload page on success + window.location.reload(); + }); + }); + }); + + // Remove user button + el.find('.js-member-demote').click(function(e) { + e.preventDefault(); + + $("body").ufModal({ + sourceUrl: site.uri.public + '/modals/organisations/o/' + $(this).data('slug') + '/members/confirm-demote', + ajaxParams: { + slug: $(this).data('slug'), + user_name: $(this).data('user_name') + }, + msgTarget: $("#alerts-page") + }); + + $("body").on('renderSuccess.ufModal', function() { + var modal = $(this).ufModal('getModal'); + var form = modal.find('.js-form'); + + form.ufForm() + .on("submitSuccess.ufForm", function() { + // Navigate or reload page on success + window.location.reload(); + }); + }); + }); + // Remove user button el.find('.js-member-remove').click(function(e) { e.preventDefault(); diff --git a/locale/en_US/messages.php b/locale/en_US/messages.php index d1c1b16..2ca65c8 100644 --- a/locale/en_US/messages.php +++ b/locale/en_US/messages.php @@ -126,10 +126,23 @@ return [ ], 'MEMBER' => [ 'NOT_FOUND' => 'User {{user_name}} is not a member of organisation {{name}}', + 'NOT_AN_ADMIN' => 'User {{user_name}} is not an administrator of organisation {{name}}', + 'ALREADY_AN_ADMIN' => 'User {{user_name}} is already an administrator of organisation {{name}}', + 'REMOVE' => 'Remove member from organisation', 'REMOVE_CONFIRM' => 'Are you sure you want to remove member {{user_name}} from the organisation {{name}}?', 'REMOVE_YES' => 'Yes, remove member', 'REMOVE_SUCCESSFUL' => 'Successfully removed member {{user_name}} from organisation {{name}}', + + 'PROMOTE_CONFIRM' => 'Are you sure you wish to promote member {{user_name}} to be an administrator of organisation {{name}}?', + 'PROMOTE_CONFIRM_EXTRA' => 'Once promoted they will be able to manage members and agents (edit, accept, reject, remove) on the organisation\'s behalf.', + 'PROMOTE_YES' => 'Yes, promote member', + 'PROMOTE_SUCCESSFUL' => 'Successfully promoted member {{user_name}} to an administrator of organisation {{name}}', + + 'DEMOTE_CONFIRM' => 'Are you sure you wish to demote administrator {{user_name}} from being an administrator of organisation {{name}}?', + 'DEMOTE_CONFIRM_EXTRA' => 'Once demoted they will no longer be able to manage members and agents on the organisation\'s behalf.', + 'DEMOTE_YES' => 'Yes, demote administrator', + 'DEMOTE_SUCCESSFUL' => 'Successfully demoted administrator {{user_name}} from being an administrator of organisation {{name}}', ], ], @@ -137,7 +150,13 @@ return [ 1 => 'Member', 2 => 'Members', - 'REMOVE' => 'Remove member', + 'REMOVE' => 'Remove member', + + 'PROMOTE' => 'Promote to administrator', + 'DEMOTE' => 'Demote to member', + + 'EDIT' => 'Edit member', + 'CHANGE_PASSWORD' => 'Change member password', ], 'ADMIN' => [ diff --git a/routes/organisation-members.php b/routes/organisation-members.php index 784dc61..a97fbf1 100644 --- a/routes/organisation-members.php +++ b/routes/organisation-members.php @@ -30,6 +30,9 @@ $app->group('/api/organisations/o/{slug}/members', function () { $this->post('/m/{user_name}/accept', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationMembersController:accept'); $this->post('/m/{user_name}/reject', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationMembersController:reject'); + + $this->put('/m/{user_name}/promote', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationMembersController:promote'); + $this->put('/m/{user_name}/demote', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationMembersController:demote'); })->add('authGuard')->add(new NoCache()); @@ -39,4 +42,6 @@ $app->group('/modals/organisations/o/{slug}/members', function () { $this->get('/confirm-remove', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationMembersController:getModalConfirmRemove'); $this->get('/confirm-accept', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationMembersController:getModalConfirmAccept'); $this->get('/confirm-reject', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationMembersController:getModalConfirmReject'); + $this->get('/confirm-promote', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationMembersController:getModalConfirmPromote'); + $this->get('/confirm-demote', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationMembersController:getModalConfirmDemote'); })->add('authGuard')->add(new NoCache()); \ No newline at end of file diff --git a/src/Controller/OrganisationMembersController.php b/src/Controller/OrganisationMembersController.php index 5f78903..4236bcc 100644 --- a/src/Controller/OrganisationMembersController.php +++ b/src/Controller/OrganisationMembersController.php @@ -387,6 +387,198 @@ class OrganisationMembersController extends SimpleController return $response->withJson([], 200); } + /** + * Promotes an organisation member to administrator status. + * + * Processes the request from the organisation details page, checking that: + * 1. The organisation exists; + * 2. The user exists; + * 3. The user is not already an administrator; + * 4. The currentUser has permission to accept; + * This route requires authorization. + * + * AuthGuard: true + * Route: /organisations/o/{slug}/members/accept + * Route Name: {none} + * Request type: GET + * + * @param Request $request + * @param Response $response + * @param array $args + */ + public function promote(Request $request, Response $response, $args) + { + /** @var \UserFrosting\Sprinkle\Account\Authorize\AuthorizationManager $authorizer */ + $authorizer = $this->ci->authorizer; + + /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ + $classMapper = $this->ci->classMapper; + + /** @var \UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface $currentUser */ + $currentUser = $this->ci->currentUser; + + /** @var \UserFrosting\Sprinkle\Core\Alert\AlertStream $ms */ + $ms = $this->ci->alerts; + + + // Fetch the organisation from the params + $organisation = $this->getOrganisationFromParams($args); + + // Fetch the user from the params + $user = $this->getUserFromParams($args); + + // If the organisation/user doesn't exist, return 404 + if (!$organisation || !$user) { + throw new NotFoundException(); + } + + // Access-controlled page + if (!$authorizer->checkAccess($currentUser, 'promote_organistion_member', [ + 'organisation' => $organisation + ])) { + throw new ForbiddenException(); + } + + // Find the mapping + $mapping = $classMapper->getClassMapping('organisation_member')::query() + ->where('organisation_id', $organisation->id) + ->where('user_id', $user->id) + ->first(); + + // User is not a member error + if (!$mapping ) { + $ms->addMessageTranslated('danger', 'ORGANISATION.MEMBER.NOT_FOUND', [ + 'name' => $organisation->name, + 'user_name' => $user->user_name + ]); + return $response->withJson([], 404); + } + + // User is already an admin + if ($mapping->flag_admin) { + $ms->addMessageTranslated('danger', 'ORGANISATION.MEMBER.ALREADY_AN_ADMIN', [ + 'name' => $organisation->name, + 'user_name' => $user->user_name + ]); + return $response->withJson([], 400); + } + + // Begin transaction - DB will be rolled back if an exception occurs + Capsule::transaction(function () use ($organisation, $user, $mapping, $currentUser) { + $mapping->flag_admin = true; + $mapping->save(); + + // Create activity record + $this->ci->userActivityLogger->info("User {$currentUser->user_name} promoted user {$user->user_name} to an administrator of organisation {$organisation->name}.", [ + 'type' => 'organisation_member_promote', + 'user_id' => $currentUser->id, + ]); + }); + + $ms->addMessageTranslated('success', 'ORGANISATION.MEMBER.PROMOTE_SUCCESSFUL', [ + 'name' => $organisation->ame, + 'user_name' => $user->user_name, + ]); + + return $response->withJson([], 200); + } + + /** + * Handles request to demote an organisation administrator. + * + * Processes the request from the organisation details page, checking that: + * 1. The organisation exists; + * 2. The user exists; + * 3. The user is an administrator; + * 4. The currentUser has permission to reject; + * This route requires authorization. + * + * AuthGuard: true + * Route: /organisations/o/{slug}/members/reject + * Route Name: {none} + * Request type: GET + * + * @param Request $request + * @param Response $response + * @param array $args + */ + public function demote(Request $request, Response $response, $args) + { + /** @var \UserFrosting\Sprinkle\Account\Authorize\AuthorizationManager $authorizer */ + $authorizer = $this->ci->authorizer; + + /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ + $classMapper = $this->ci->classMapper; + + /** @var \UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface $currentUser */ + $currentUser = $this->ci->currentUser; + + /** @var \UserFrosting\Sprinkle\Core\Alert\AlertStream $ms */ + $ms = $this->ci->alerts; + + + // Fetch the organisation from the params + $organisation = $this->getOrganisationFromParams($args); + + // Fetch the user from the params + $user = $this->getUserFromParams($args); + + // If the organisation/user doesn't exist, return 404 + if (!$organisation || !$user) { + throw new NotFoundException(); + } + + // Access-controlled page + if (!$authorizer->checkAccess($currentUser, 'promote_organistion_member', [ + 'organisation' => $organisation + ])) { + throw new ForbiddenException(); + } + + // Find the mapping + $mapping = $classMapper->getClassMapping('organisation_member')::query() + ->where('organisation_id', $organisation->id) + ->where('user_id', $user->id) + ->first(); + + // User is not a member error + if (!$mapping ) { + $ms->addMessageTranslated('danger', 'ORGANISATION.MEMBER.NOT_FOUND', [ + 'name' => $organisation->name, + 'user_name' => $user->user_name + ]); + return $response->withJson([], 404); + } + + // User is already an admin + if (!$mapping->flag_admin) { + $ms->addMessageTranslated('danger', 'ORGANISATION.MEMBER.NOT_AN_ADMIN', [ + 'name' => $organisation->name, + 'user_name' => $user->user_name + ]); + return $response->withJson([], 400); + } + + // Begin transaction - DB will be rolled back if an exception occurs + Capsule::transaction(function () use ($organisation, $user, $mapping, $currentUser) { + $mapping->flag_admin = false; + $mapping->save(); + + // Create activity record + $this->ci->userActivityLogger->info("User {$currentUser->user_name} demoted user {$user->user_name} to an administrator of organisation {$organisation->name}.", [ + 'type' => 'organisation_member_promote', + 'user_id' => $currentUser->id, + ]); + }); + + $ms->addMessageTranslated('success', 'ORGANISATION.MEMBER.DEMOTE_SUCCESSFUL', [ + 'name' => $organisation->ame, + 'user_name' => $user->user_name, + ]); + + return $response->withJson([], 200); + } + /** * Accepts a request to join organisation. @@ -721,7 +913,8 @@ class OrganisationMembersController extends SimpleController $sprunje->extendQuery(function ($query) use ($classMapper, $organisation) { return $query ->where('organisation_id', $organisation->id) - ->addSelect('organisation_members.flag_approved AS membership_approved'); + ->addSelect('organisation_members.flag_approved AS membership_approved') + ->addSelect('organisation_members.flag_admin AS organisation_admin'); }); // Be careful how you consume this data - it has not been escaped and contains untrusted user-supplied content. @@ -965,6 +1158,102 @@ class OrganisationMembersController extends SimpleController ]); } + /** + * Get promote member confirmation modal. + * + * @param Request $request + * @param Response $response + * @param array $args + * + * @throws NotFoundException If organisation/member is not found + * @throws ForbiddenException If user is not authorized to access page + * @throws BadRequestException + */ + public function getModalConfirmPromote(Request $request, Response $response, $args) + { + /** @var \UserFrosting\Sprinkle\Account\Authorize\AuthorizationManager $authorizer */ + $authorizer = $this->ci->authorizer; + + /** @var \UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface $currentUser */ + $currentUser = $this->ci->currentUser; + + + // GET parameters + $params = $request->getQueryParams(); + + $organisation = $this->getOrganisationFromParams($params); + + $user = $this->getUserFromParams($params); + + // Check organisation & user exists + if (!$organisation || !$user) { + throw new NotFoundException(); + } + + // Access-controlled page + if (!$authorizer->checkAccess($currentUser, 'promote_organistion_member', [ + 'organisation' => $organisation, + ])) { + throw new ForbiddenException(); + } + + return $this->ci->view->render($response, 'modals/confirm-promote-organisation-member.html.twig', [ + 'organisation' => $organisation, + 'user' => $user, + 'form' => [ + 'action' => "api/organisations/o/{$organisation->slug}/members/m/{$user->user_name}/promote", + ], + ]); + } + + /** + * Get demote administrator confirmation modal. + * + * @param Request $request + * @param Response $response + * @param array $args + * + * @throws NotFoundException If organisation/member is not found + * @throws ForbiddenException If user is not authorized to access page + * @throws BadRequestException + */ + public function getModalConfirmDemote(Request $request, Response $response, $args) + { + /** @var \UserFrosting\Sprinkle\Account\Authorize\AuthorizationManager $authorizer */ + $authorizer = $this->ci->authorizer; + + /** @var \UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface $currentUser */ + $currentUser = $this->ci->currentUser; + + + // GET parameters + $params = $request->getQueryParams(); + + $organisation = $this->getOrganisationFromParams($params); + + $user = $this->getUserFromParams($params); + + // Check organisation & user exists + if (!$organisation || !$user) { + throw new NotFoundException(); + } + + // Access-controlled page + if (!$authorizer->checkAccess($currentUser, 'promote_organistion_member', [ + 'organisation' => $organisation, + ])) { + throw new ForbiddenException(); + } + + return $this->ci->view->render($response, 'modals/confirm-demote-organisation-member.html.twig', [ + 'organisation' => $organisation, + 'user' => $user, + 'form' => [ + 'action' => "api/organisations/o/{$organisation->slug}/members/m/{$user->user_name}/demote", + ], + ]); + } + /** * Send approval email for specified organisation and confirmation to user. diff --git a/src/Database/Seeds/OrganisationPermissions.php b/src/Database/Seeds/OrganisationPermissions.php index 0c4e742..6cefc10 100644 --- a/src/Database/Seeds/OrganisationPermissions.php +++ b/src/Database/Seeds/OrganisationPermissions.php @@ -180,6 +180,13 @@ class OrganisationPermissions extends BaseSeed 'conditions' => "similar_orgs(self.id, user.id) && in(property,['user_name','name','locale','email','phone_number','activities'])", 'description' => 'View certain properties of any user in their organisation.', ]), + + 'promote_organistion_member' => new Permission([ + 'slug' => 'promote_organistion_member', + 'name' => 'Promote organisation member/Demote organisation administrator', + 'conditions' => "is_organisation_admin(self.id) && (is_organisation_member(user.id,organisation.id) || is_organisation_admin(user.id,organisation.id))", + 'description' => 'Promote an organisation member to administrator status or demote and administrator to member status.', + ]), ]; } @@ -224,6 +231,7 @@ class OrganisationPermissions extends BaseSeed $permissions['delete_organisation']->id, $permissions['uri_organisations']->id, $permissions['uri_organisation']->id, + $permissions['promote_organistion_member']->id, ]); } @@ -244,6 +252,7 @@ class OrganisationPermissions extends BaseSeed $permissions['uri_deleted_organisations']->id, $permissions['restore_organisation']->id, $permissions['permenent_delete_organisation']->id, + $permissions['promote_organistion_member']->id, ]); } @@ -260,6 +269,7 @@ class OrganisationPermissions extends BaseSeed $permissions['accept_organisation_join_request']->id, $permissions['update_org_user_field']->id, $permissions['view_org_user_field']->id, + $permissions['promote_organistion_member']->id ]); } } diff --git a/templates/modals/confirm-demote-organisation-member.html.twig b/templates/modals/confirm-demote-organisation-member.html.twig new file mode 100644 index 0000000..1c489f4 --- /dev/null +++ b/templates/modals/confirm-demote-organisation-member.html.twig @@ -0,0 +1,17 @@ +{% extends "modals/modal.html.twig" %} + +{% block modal_title %}{{translate("ORGANISATION.MEMBER.DEMOTE")}}{% endblock %} + +{% block modal_body %} +
+ {% include "forms/csrf.html.twig" %} +
+
+

{{translate("ORGANISATION.MEMBER.DEMOTE_CONFIRM", {user_name: user.user_name, name: organisation.name})}}
{{translate("ORGANISATION.MEMBER.DEMOTE_CONFIRM_EXTRA")}}

+
+
+ + +
+
+{% endblock %} diff --git a/templates/modals/confirm-promote-organisation-member.html.twig b/templates/modals/confirm-promote-organisation-member.html.twig new file mode 100644 index 0000000..1deae39 --- /dev/null +++ b/templates/modals/confirm-promote-organisation-member.html.twig @@ -0,0 +1,19 @@ +{% extends "modals/modal.html.twig" %} + +{% block modal_title %}{{translate("ORGANISATION.MEMBER.PROMOTE")}}{% endblock %} + +{% block modal_body %} +
+ {% include "forms/csrf.html.twig" %} +
+
+

{{translate("ORGANISATION.MEMBER.PROMOTE_CONFIRM", {user_name: user.user_name, name: organisation.name})}}

+
+

{{translate("ORGANISATION.MEMBER.PROMOTE_CONFIRM_EXTRA")}}

+
+
+ + +
+
+{% endblock %} diff --git a/templates/tables/organisation-members.html.twig b/templates/tables/organisation-members.html.twig index 5d94bce..551950a 100644 --- a/templates/tables/organisation-members.html.twig +++ b/templates/tables/organisation-members.html.twig @@ -61,14 +61,27 @@ {{ else }} + {{#ifx row.organisation_admin '==' 1 }} +
  • + + {% endverbatim %}{{translate("MEMBER.DEMOTE")}}{% verbatim %} + +
  • + {{ else }} +
  • + + {% endverbatim %}{{translate("MEMBER.PROMOTE")}}{% verbatim %} + +
  • + {{/ifx}}
  • - {% endverbatim %}{{translate("USER.EDIT")}}{% verbatim %} + {% endverbatim %}{{translate("MEMBER.EDIT")}}{% verbatim %}
  • - {% endverbatim %}{{translate("USER.ADMIN.CHANGE_PASSWORD")}}{% verbatim %} + {% endverbatim %}{{translate("MEMBER.CHANGE_PASSWORD")}}{% verbatim %}