diff --git a/assets/avsdev/js/widgets/organisations.js b/assets/avsdev/js/widgets/organisations.js index aefcc85..cd1b9bb 100644 --- a/assets/avsdev/js/widgets/organisations.js +++ b/assets/avsdev/js/widgets/organisations.js @@ -216,6 +216,73 @@ function bindOrganisationButtons(el, options) { }); }); + // Join organisation button + el.find('.js-organisation-join').click(function(e) { + e.preventDefault(); + + var data = {}; + data[site.csrf.keys.name] = site.csrf.name; + data[site.csrf.keys.value] = site.csrf.value; + + var url = site.uri.public + '/api/organisations/o/' + $(this).data('slug') + '/members'; + var debugAjax = (typeof site !== "undefined") && site.debug.ajax; + + return $.ajax({ + type: "POST", + url: url, + data: data, + dataType: debugAjax ? 'html' : 'json', + }).fail(function(jqXHR) { + // Error messages + if (debugAjax && jqXHR.responseText) { + document.write(jqXHR.responseText); + document.close(); + } else { + console.log("Error (" + jqXHR.status + "): " + jqXHR.responseText); + + // Display errors on failure + // TODO: ufAlerts widget should have a 'destroy' method + if (!$("#alerts-page").data('ufAlerts')) { + $("#alerts-page").ufAlerts(); + } else { + $("#alerts-page").ufAlerts('clear'); + } + + $("#alerts-page").ufAlerts('fetch').ufAlerts('render'); + } + + return jqXHR; + }).done(function(response) { + window.location.reload(); + }); + }); + + // Cancel a registration request + el.find('.js-organisation-cancelJoin').click(function(e) { + e.preventDefault(); + + $("body").ufModal({ + sourceUrl: site.uri.public + "/modals/organisations/o/" + $(this).data('slug') + "/members/confirm-cancel", + ajaxParams: { + slug: $(this).data('slug') + }, + 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 + if (options.delete_redirect) window.location.href = options.delete_redirect; + else window.location.reload(); + }); + }); + }); + + // Leave organisation button el.find('.js-organisation-leave').click(function(e) { e.preventDefault(); diff --git a/config/default.php b/config/default.php index 8369dfa..a78ce55 100644 --- a/config/default.php +++ b/config/default.php @@ -24,7 +24,9 @@ return [ 'timeout' => -1, ], 'membership' => [ + 'require_approval' => true, 'single_membership' => false, + 'timeout' => -1, ], ], ]; diff --git a/locale/en_US/messages.php b/locale/en_US/messages.php index abad74d..4b95af0 100644 --- a/locale/en_US/messages.php +++ b/locale/en_US/messages.php @@ -28,6 +28,8 @@ return [ 'EDIT' => 'Edit organistion', 'UPDATE' => 'Details updated for organistion {{name}}', + 'JOIN_SUCCESSFUL' => 'Successfully joined organisation {{name}}', + 'LEAVE' => 'Leave organisation', 'LEAVE_CONFIRM' => 'Are you sure you want to leave the organisation {{name}}?', 'LEAVE_YES' => 'Yes, leave organisation.', @@ -56,6 +58,8 @@ return [ 'PERMENENT_DELETE_YES' => 'Yes, permenently delete organisation', 'PERMENENT_DELETION_SUCCESSFUL' => 'Successfully permenently deleted organisation {{name}}', + 'NOT_A_MEMBER' => 'You are not a member of organisation {{name}}.', + 'NAME' => [ 1 => 'Organisation name', @@ -82,6 +86,22 @@ return [ 'APPROVE' => 'Approve organisation registration', 'DENY' => 'Deny organisation registration', ], + 'JOIN_REQUEST' => [ + 'SUBMIT_SUCCESSFUL' => 'Request to join organisation {{name}} sent', + + 'CANCEL' => 'Cancel request to join organisation', + 'CANCEL_CONFIRM' => 'Are you sure you want to cancel your request to join the organisation {{name}}?', + 'CANCEL_YES' => 'Yes, cancel request', + 'CANCEL_SUCCESSFUL' => 'Successfully cancelled request to join organisation {{name}}', + + 'ALREADY_MEMBER' => 'You are already a member of the organisation {{name}}.', + 'REQUEST_PENDING' => 'You have already requested to join organisation {{name}}. Your request is awaiting approval.', + 'NO_REQUEST' => 'You have no pending requests to join organisation {{name}}.', + + 'TOKEN_NOT_FOUND' => 'User join request token does not exist / user join request has already been accepted/rejected.', + 'ACCEPTED' => 'You have successfully accepted the request from user {{user_name}} to join organisation {{organisation_name}}.', + 'REJECTED' => 'You have successfully rejected the request from user {{user_name}} to join organisation {{organisation_name}}.', + ], ], 'MEMBER' => [ diff --git a/routes/organisation-members.php b/routes/organisation-members.php index 6d742e9..80c27c8 100644 --- a/routes/organisation-members.php +++ b/routes/organisation-members.php @@ -12,13 +12,26 @@ use UserFrosting\Sprinkle\Core\Util\NoCache; /* * Routes for administrative organisation member management. */ +$app->group('/organisations/members', function () { + $this->get('/accept', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationMembersController:acceptToken'); + $this->get('/reject', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationMembersController:rejectToken'); +})->add('authGuard')->add(new NoCache()); + $app->group('/api/organisations/o/{slug}/members', function () { $this->get('', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationMembersController:getList'); + $this->post('', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationMembersController:join'); + $this->delete('', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationMembersController:leave'); + + $this->delete('/cancel', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationMembersController:cancel'); + + $this->put('/m/{user_name}/accept', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationMembersController:accept'); + $this->put('/m/{user_name}/reject', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationMembersController:reject'); })->add('authGuard')->add(new NoCache()); $app->group('/modals/organisations/o/{slug}/members', function () { $this->get('/confirm-leave', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationMembersController:getModalConfirmLeave'); + $this->get('/confirm-cancel', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationMembersController:getModalConfirmCancel'); })->add('authGuard')->add(new NoCache()); \ No newline at end of file diff --git a/schema/requests/organisation/membership-verify.yaml b/schema/requests/organisation/membership-verify.yaml new file mode 100644 index 0000000..01f3155 --- /dev/null +++ b/schema/requests/organisation/membership-verify.yaml @@ -0,0 +1,6 @@ +--- +token: + validators: + required: + label: validation token + message: VALIDATION.REQUIRED diff --git a/src/Controller/OrganisationMembersController.php b/src/Controller/OrganisationMembersController.php index b84e7da..b1f6902 100644 --- a/src/Controller/OrganisationMembersController.php +++ b/src/Controller/OrganisationMembersController.php @@ -17,10 +17,16 @@ use UserFrosting\Fortress\RequestDataTransformer; use UserFrosting\Fortress\RequestSchema; use UserFrosting\Fortress\ServerSideValidator; use UserFrosting\Sprinkle\Organisations\Database\Models\Organisation; +use UserFrosting\Sprinkle\Organisations\Database\Models\OrganisationMember; use UserFrosting\Sprinkle\Core\Controller\SimpleController; use UserFrosting\Support\Exception\BadRequestException; use UserFrosting\Support\Exception\ForbiddenException; use UserFrosting\Support\Exception\NotFoundException; +use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface; +use UserFrosting\Sprinkle\Organisations\Database\Models\User; +use UserFrosting\Sprinkle\Organisations\Database\Models\Interfaces\OrganisationInterface; +use UserFrosting\Sprinkle\Core\Mail\TwigMailMessage; +use UserFrosting\Sprinkle\Core\Mail\EmailRecipient; /** * Controller class for organisation member-related requests, including listing members, CRUD for members, etc. @@ -29,6 +35,199 @@ use UserFrosting\Support\Exception\NotFoundException; */ class OrganisationMembersController extends SimpleController { + /** + * Processes the request to join an organisation: + * 1. The user is not already a member of the organisation or pending; + * 2. The user has permission to join organisations; + * 3. The submitted data is valid. + * This route requires authentication. + * + * Request type: POST + * + * @param Request $request + * @param Response $response + * @param array $args + * + * @throws ForbiddenException If user is not authorized to access page + */ + public function join(Request $request, Response $response, $args) + { + $organisation = $this->getOrganisationFromParams($args); + + // If the organisation doesn't exist, return 404 + if (!$organisation) { + throw new NotFoundException(); + } + + /** @var \UserFrosting\Sprinkle\Account\Authorize\AuthorizationManager $authorizer */ + $authorizer = $this->ci->authorizer; + + /** @var \UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface $currentUser */ + $currentUser = $this->ci->currentUser; + + // Access-controlled page + if (!$authorizer->checkAccess($currentUser, 'join_organisation')) { + throw new ForbiddenException(); + } + + /** @var \UserFrosting\Sprinkle\Core\Alert\AlertStream $ms */ + $ms = $this->ci->alerts; + + // Check if the user is a member of the organisation, pending or no relation at all + $memberCheck = $organisation->members()->where('user_id', $currentUser->id)->withPivot('flag_approved')->first(); + if ($memberCheck) { + if ($memberCheck->pivot->flag_approved) { + $ms->addMessageTranslated('danger', 'ORGANISATION.JOIN_REQUEST.ALREADY_MEMBER', [ + 'name' => $organisation->name + ]); + } else { + $ms->addMessageTranslated('danger', 'ORGANISATION.JOIN_REQUEST.REQUEST_PENDING', [ + 'name' => $organisation->name + ]); + } + return $response->withJson([], 400); + } + + /** @var \UserFrosting\Support\Repository\Repository $config */ + $config = $this->ci->config; + + /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ + $classMapper = $this->ci->classMapper; + + // All checks passed! log events/activities and create organisation + // Begin transaction - DB will be rolled back if an exception occurs + Capsule::transaction(function () use ($classMapper, $ms, $organisation, $currentUser, $config) { + $organisation->members()->attach($currentUser->id, [ + 'flag_admin' => false, + 'flag_approved' => !$config['organisation']['membership']['require_approval'], + ]); + + $organisation->save(); + + // Create activity record + if ($config['organisation']['membership']['require_approval']) { + $this->ci->userActivityLogger->info("User {$currentUser->user_name} requested to join organisation {$organisation->name}.", [ + 'type' => 'organisation_join', + 'user_id' => $currentUser->id, + ]); + + $timeout = $this->ci->config['organisation.membership.timeout']; + + // Find the mapping + $tokenOwner = $classMapper->getClassMapping('organisation_member')::query() + ->where('organisation_id', $organisation->id) + ->where('user_id', $currentUser->id) + ->first(); + + // Try to generate a new approval request + $approval = $this->ci->repoOrganisationMembershipApproval->create($tokenOwner, $timeout); + + $this->sendApprovalEmail($currentUser, $organisation, $approval->getToken()); + + $ms->addMessageTranslated('success', 'ORGANISATION.JOIN_REQUEST.SUBMIT_SUCCESSFUL', [ + 'name' => $organisation->name + ]); + } else { + $this->ci->userActivityLogger->info("User {$currentUser->user_name} has joined organisation {$organisation->name}.", [ + 'type' => 'organisation_join', + 'user_id' => $currentUser->id, + ]); + + $ms->addMessageTranslated('success', 'ORGANISATION.JOIN_SUCCESSFUL', [ + 'name' => $organisation->name + ]); + } + + }); + + return $response->withJson([], 200); + } + + /** + * Processes the request to cancel a join organisation request. + * + * Before doing so, checks that: + * 1. The submitted data is valid. + * 2. The user has a pending join request/is not already a member. + * This route requires authentication. + * + * Request type: DELETE + * + * @param Request $request + * @param Response $response + * @param array $args + * + * @throws NotFoundException If organisation is not found + * @throws ForbiddenException If user is not authorized to access page + * @throws BadRequestException + */ + public function cancel(Request $request, Response $response, $args) + { + $organisation = $this->getOrganisationFromParams($args); + + // If the organisation doesn't exist, return 404 + if (!$organisation) { + throw new NotFoundException(); + } + + /** @var \UserFrosting\Sprinkle\Account\Authorize\AuthorizationManager $authorizer */ + $authorizer = $this->ci->authorizer; + + /** @var \UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface $currentUser */ + $currentUser = $this->ci->currentUser; + + /** @var \UserFrosting\Support\Repository\Repository $config */ + $config = $this->ci->config; + + /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ + $classMapper = $this->ci->classMapper; + + /** @var \UserFrosting\Sprinkle\Core\Alert\AlertStream $ms */ + $ms = $this->ci->alerts; + + // Check if the user is a member of the organisation, pending or no relation at all + $memberCheck = $organisation->members()->where('user_id', $currentUser->id)->withPivot('flag_approved')->first(); + if ($memberCheck) { + if ($memberCheck->pivot->flag_approved) { + $ms->addMessageTranslated('danger', 'ORGANISATION.JOIN_REQUEST.ALREADY_MEMBER', [ + 'name' => $organisation->name + ]); + return $response->withJson([], 400); + } + } else { + $ms->addMessageTranslated('danger', 'ORGANISATION.JOIN_REQUEST.NO_REQUEST', [ + 'name' => $organisation->name + ]); + return $response->withJson([], 400); + } + + // Begin transaction - DB will be rolled back if an exception occurs + Capsule::transaction(function () use ($organisation, $currentUser) { + $organisation->members()->detach($currentUser->id); + + if ($config['organisation']['membership']['require_approval']) { + // Find the mapping + $tokenOwner = $classMapper->getClassMapping('organisation_member')::query() + ->where('organisation_id', $organisation->id) + ->where('user_id', $currentUser->id) + ->first(); + + $approval = $this->ci->repoOrganisationMembershipApproval->removeExisting($tokenOwner); + } + + // Create activity record + $this->ci->userActivityLogger->info("User {$currentUser->user_name} cancelled the request to join the organisation {$organisation->name}.", [ + 'type' => 'organisation_join', + 'user_id' => $currentUser->id, + ]); + }); + + $ms->addMessageTranslated('success', 'ORGANISATION.JOIN_REQUEST.CANCEL_SUCCESSFUL', [ + 'name' => $organisation->name, + ]); + + return $response->withJson([], 200); + } /** * Processes the request to leave an organisation. @@ -77,6 +276,15 @@ class OrganisationMembersController extends SimpleController /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ $classMapper = $this->ci->classMapper; + // Check if the user is a member of the organisation, pending or no relation at all + $memberCheck = $organisation->members()->where('user_id', $currentUser->id)->withPivot('flag_approved')->first(); + if (!$memberCheck || !$memberCheck->pivot->flag_approved) { + $ms->addMessageTranslated('danger', 'ORGANISATION.NOT_A_MEMBER', [ + 'name' => $organisation->name + ]); + return $response->withJson([], 400); + } + // Begin transaction - DB will be rolled back if an exception occurs Capsule::transaction(function () use ($organisation, $currentUser) { $currentUser->organisations()->detach($organisation->id); @@ -98,6 +306,270 @@ class OrganisationMembersController extends SimpleController return $response->withJson([], 200); } + + /** + * Accepts a request to join organisation. + * + * Processes the request from the organisation details page, checking that: + * 1. The organisation exists; + * 2. The user exists; + * 3. The user is not already a member; + * 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 accept(Request $request, Response $response, $args) + { + // 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(); + } + + /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ + $classMapper = $this->ci->classMapper; + + // Find the mapping + $tokenOwner = $classMapper->getClassMapping('organisation_member')::query() + ->where('organisation_id', $organisation->id) + ->where('user_id', $user->id) + ->first(); + + // Process the acceptance emails etc + if (!$this->processAcceptToken($tokenOwner)) { + return $response->withJson([], 400); + } + + return $response->withJson([], 200); + } + + /** + * Accepts a request to join organisation by using an emailed token. + * + * Processes the request from the organisation details page, checking that: + * 1. The token is valid; + * 2. There is a currentUser logged in that has permission to accept; + * 3. The organisation exists; + * 4. The user exists; + * 3. The user is not already a member; + * This route requires authorization. + * + * AuthGuard: true + * Route: /organisations/members/accept + * Route Name: {none} + * Request type: GET + * + * @param Request $request + * @param Response $response + * @param array $args + */ + public function acceptToken(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; + + // Access-controlled page + if (!$authorizer->checkAccess($currentUser, 'approve_organisation_membership', [ + 'organisation' => $organisation + ])) { + throw new ForbiddenException(); + } + + + /** @var \UserFrosting\Sprinkle\Core\Alert\AlertStream $ms */ + $ms = $this->ci->alerts; + + // GET parameters + $params = $request->getQueryParams(); + + // Load request schema + $schema = new RequestSchema('schema://requests/organisation/membership-verify.yaml'); + + // Whitelist and set parameter defaults + $transformer = new RequestDataTransformer($schema); + $data = $transformer->transform($params); + + // Validate, and halt on validation errors. This is a GET request, so we redirect on validation error. + $validator = new ServerSideValidator($schema, $this->ci->translator); + if (!$validator->validate($data)) { + $ms->addValidationErrors($validator); + return $response->withRedirect($this->ci->router->pathFor('dashboard')); + } + + // Find the token owner if valid + $owner_id = $this->ci->repoOrganisationMembershipApproval->findOwner($data['token']); + if (!$owner_id) { + $ms->addMessageTranslated('danger', 'ORGANISATION.JOIN_REQUEST.TOKEN_NOT_FOUND'); + return $response->withRedirect($this->ci->router->pathFor('dashboard')); + } + + + /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ + $classMapper = $this->ci->classMapper; + + // Fetch the mapping + $tokenOwner = $classMapper->getClassMapping('organisation_member')::query() + ->where('map_id', $owner_id) + ->first(); + + + // Process the acceptance emails etc + if (!$this->processAcceptToken($tokenOwner)) { + return $response->withRedirect($this->ci->router->pathFor('dashboard')); + } + + // Forward to organisation page + return $response->withRedirect($this->ci->router->pathFor('dashboard')); + } + + /** + * Rejects a request to join organisation. + * + * Processes the request from the organisation details page, checking that: + * 1. The organisation exists; + * 2. The user exists; + * 3. The user is not already a member; + * 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 reject(Request $request, Response $response, $args) + { + // 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(); + } + + /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ + $classMapper = $this->ci->classMapper; + + // Find the mapping + $tokenOwner = $classMapper->getClassMapping('organisation_member')::query() + ->where('organisation_id', $organisation->id) + ->where('user_id', $user->id) + ->first(); + + // Process the acceptance emails etc + if (!$this->processRejectToken($tokenOwner)) { + return $response->withJson([], 400); + } + + return $response->withJson([], 200); + } + + /** + * Rejects a request to join organisation by using an emailed token. + * + * Processes the request from the organisation details page, checking that: + * 1. The token is valid; + * 2. There is a currentUser logged in that has permission to reject; + * 3. The organisation exists; + * 4. The user exists; + * 3. The user is not already a member; + * This route requires authorization. + * + * AuthGuard: true + * Route: /organisations/members/reject + * Route Name: {none} + * Request type: GET + * + * @param Request $request + * @param Response $response + * @param array $args + */ + public function rejectToken(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; + + // Access-controlled page + if (!$authorizer->checkAccess($currentUser, 'approve_organisation_membership', [ + 'organisation' => $organisation + ])) { + throw new ForbiddenException(); + } + + + /** @var \UserFrosting\Sprinkle\Core\Alert\AlertStream $ms */ + $ms = $this->ci->alerts; + + // GET parameters + $params = $request->getQueryParams(); + + // Load request schema + $schema = new RequestSchema('schema://requests/organisation/membership-verify.yaml'); + + // Whitelist and set parameter defaults + $transformer = new RequestDataTransformer($schema); + $data = $transformer->transform($params); + + // Validate, and halt on validation errors. This is a GET request, so we redirect on validation error. + $validator = new ServerSideValidator($schema, $this->ci->translator); + if (!$validator->validate($data)) { + $ms->addValidationErrors($validator); + return $response->withRedirect($this->ci->router->pathFor('dashboard')); + } + + // Find the token owner if valid + $owner_id = $this->ci->repoOrganisationMembershipApproval->findOwner($data['token']); + if (!$owner_id) { + $ms->addMessageTranslated('danger', 'ORGANISATION.JOIN_REQUEST.TOKEN_NOT_FOUND'); + return $response->withRedirect($this->ci->router->pathFor('dashboard')); + } + + + /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ + $classMapper = $this->ci->classMapper; + + // Fetch the mapping + $tokenOwner = $classMapper->getClassMapping('organisation_member')::query() + ->where('map_id', $owner_id) + ->first(); + + + // Process the rejectance emails etc + if (!$this->processRejectToken($tokenOwner)) { + return $response->withRedirect($this->ci->router->pathFor('dashboard')); + } + + // Forward to organisation page + return $response->withRedirect($this->ci->router->pathFor('dashboard')); + } + + /** * Returns a list of organisation members. * @@ -195,6 +667,228 @@ class OrganisationMembersController extends SimpleController ]); } + /** + * Get cancel join request confirmation modal. + * + * @param Request $request + * @param Response $response + * @param array $args + * + * @throws NotFoundException If organisation is not found + * @throws ForbiddenException If user is not authorized to access page + * @throws BadRequestException + */ + public function getModalConfirmCancel(Request $request, Response $response, $args) + { + // GET parameters + $params = $request->getQueryParams(); + + $organisation = $this->getOrganisationFromParams($params); + + // If the organisation no longer exists, forward to main organisation listing page + if (!$organisation) { + throw new NotFoundException(); + } + + /** @var \UserFrosting\Sprinkle\Account\Authorize\AuthorizationManager $authorizer */ + $authorizer = $this->ci->authorizer; + + /** @var \UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface $currentUser */ + $currentUser = $this->ci->currentUser; + + // Access-controlled page + if (!$authorizer->checkAccess($currentUser, 'join_organisation', [ + 'organisation' => $organisation, + ])) { + throw new ForbiddenException(); + } + + /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ + $classMapper = $this->ci->classMapper; + + return $this->ci->view->render($response, 'modals/confirm-cancel-organisation-join.html.twig', [ + 'organisation' => $organisation, + 'form' => [ + 'action' => "api/organisations/o/{$organisation->slug}/members/cancel", + ], + ]); + } + + + /** + * Send approval email for specified organisation and confirmation to user. + * + * @param UserInterface $requester The user to send the confirmation of registration to + * @param UserInterface $organisation The organisation to send the email for + */ + protected function sendApprovalEmail(UserInterface $requester, OrganisationInterface $organisation, $token) + { + $timeout = $this->ci->config['organisation.membership.timeout']; + + // Create and send approval email + $message = new TwigMailMessage($this->ci->view, 'mail/organisation-membership-request.html.twig'); + + $message->from($this->ci->config['address_book.admin']) + ->addParams([ + 'requester' => $requester, + 'organisation' => $organisation, + 'token' => $token, + 'approval_expiration' => ($timeout > 0 ? floor($timeout / 86400) . ' days' : false), + ]); + + $recipientsQuery = $organisation->administrators(); + + if ($recipientsQuery->count() == 0) { + $role = $this->ci->classMapper->getClassMapping('role')::where('slug', 'organisations-admin')->with('users')->first(); + if ($role->users()->count() == 0) { + $role = $this->ci->classMapper->getClassMapping('role')::where('slug', 'site-admin')->with('users')->first(); + } + $recipientsQuery = $role->users(); + } + + $recipients = $recipientsQuery->get(); + + foreach($recipients as $recipient) { + $message->addEmailRecipient(new EmailRecipient($recipient->email, $recipient->full_name)); + $message->addParams([ 'recipient' => $recipient ]); + $this->ci->mailer->send($message); + $message->addParams([ 'recipient' => null ]); + $message->clearRecipients(); + } + } + + /** + * Send approved email for specified organisation. + * + * @param UserInterface $requester The user to send the approved notice to + * @param UserInterface $organisation The organisation to send the email for + */ + protected function sendAcceptedEmail(UserInterface $requester, OrganisationInterface $organisation) + { + $message = new TwigMailMessage($this->ci->view, 'mail/organisation-membership-accepted.html.twig'); + + $message->from($this->ci->config['address_book.admin']) + ->addParams([ + 'recipient' => $requester, + 'organisation' => $organisation + ]); + + $message->addEmailRecipient(new EmailRecipient($requester->email, $requester->full_name)); + $this->ci->mailer->send($message); + } + + /** + * Send rejected email for specified organisation. + * + * @param UserInterface $requester The user to send the rejection notice to + * @param UserInterface $organisation The organisation to send the email for + */ + protected function sendRejectedEmail(UserInterface $requester, OrganisationInterface $organisation) + { + $message = new TwigMailMessage($this->ci->view, 'mail/organisation-membership-rejected.html.twig'); + + $message->from($this->ci->config['address_book.admin']) + ->addParams([ + 'recipient' => $requester, + 'organisation' => $organisation + ]); + + $message->addEmailRecipient(new EmailRecipient($requester->email, $requester->full_name)); + $this->ci->mailer->send($message); + } + + + protected function processAcceptToken($tokenOwner) + { + /** @var \UserFrosting\Sprinkle\Account\Authorize\AuthorizationManager $authorizer */ + $authorizer = $this->ci->authorizer; + + /** @var \UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface $currentUser */ + $currentUser = $this->ci->currentUser; + + // Access-controlled page + if (!$authorizer->checkAccess($currentUser, 'accept_organisation_membership')) { + throw new ForbiddenException(); + } + + + /** @var \UserFrosting\Sprinkle\Core\Alert\AlertStream $ms */ + $ms = $this->ci->alerts; + + + // Try and complete the token, bail if not found + $verification = $this->ci->repoOrganisationMembershipApproval->completeForOwner($tokenOwner, ['approved' => true, 'approver_id' => $currentUser->id]); + if (!$verification) { + $ms->addMessageTranslated('danger', 'ORGANISATION.JOIN_REQUEST.TOKEN_NOT_FOUND'); + return false; + } + + /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ + $classMapper = $this->ci->classMapper; + + $organisation = $tokenOwner->organisation()->first(); + $requester = $tokenOwner->user()->first(); + + $this->sendAcceptedEmail($requester, $organisation); + + $this->ci->userActivityLogger->info("User {$currentUser->user_name} approved the request for user {$requester->user_name} to join organisation {$organisation->name}.", [ + 'type' => 'organisation_member_approved', + 'user_id' => $currentUser->id, + ]); + + $ms->addMessageTranslated('success', 'ORGANISATION.JOIN_REQUEST.ACCEPTED', [ + 'user_name' => $requester->user_name, + 'organisation_name' => $organisation->name + ]); + + return true; + } + + protected function processRejectToken($tokenOwner) + { + /** @var \UserFrosting\Sprinkle\Account\Authorize\AuthorizationManager $authorizer */ + $authorizer = $this->ci->authorizer; + + /** @var \UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface $currentUser */ + $currentUser = $this->ci->currentUser; + + // Access-controlled page + if (!$authorizer->checkAccess($currentUser, 'accept_organisation_membership')) { + throw new ForbiddenException(); + } + + + /** @var \UserFrosting\Sprinkle\Core\Alert\AlertStream $ms */ + $ms = $this->ci->alerts; + + // Try and complete the token, bail if not found + $verification = $this->ci->repoOrganisationMembershipApproval->completeForOwner($tokenOwner, ['approved' => false, 'approver_id' => $currentUser->id]); + if (!$verification) { + $ms->addMessageTranslated('danger', 'ORGANISATION.JOIN_REQUEST.TOKEN_NOT_FOUND'); + return false; + } + + /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ + $classMapper = $this->ci->classMapper; + + $organisation = $tokenOwner->organisation()->first(); + $requester = $tokenOwner->user()->first(); + + $this->sendRejectedEmail($requester, $organisation); + + $this->ci->userActivityLogger->info("User {$currentUser->user_name} rejected the request for user {$requester->user_name} to join organisation {$organisation->name}.", [ + 'type' => 'organisation_member_approved', + 'user_id' => $currentUser->id, + ]); + + $ms->addMessageTranslated('success', 'ORGANISATION.JOIN_REQUEST.REJECTED', [ + 'user_name' => $requester->user_name, + 'organisation_name' => $organisation->name + ]); + + return true; + } + /** * Get organisation from params. @@ -237,4 +931,46 @@ class OrganisationMembersController extends SimpleController return $organisation; } + + /** + * Get User instance from params. + * + * @param string[] $params + * + * @throws BadRequestException + * + * @return User|null + */ + protected function getUserFromParams(array $params): ?User + { + // Load the request schema + $schema = new RequestSchema('schema://requests/user/get-by-username.yaml'); + + // Whitelist and set parameter defaults + $transformer = new RequestDataTransformer($schema); + $data = $transformer->transform($params); + + // Validate, and throw exception on validation errors. + $validator = new ServerSideValidator($schema, $this->ci->translator); + if (!$validator->validate($data)) { + // TODO: encapsulate the communication of error messages from ServerSideValidator to the BadRequestException + $e = new BadRequestException(); + foreach ($validator->errors() as $idx => $field) { + foreach ($field as $eidx => $error) { + $e->addUserMessage($error); + } + } + + throw $e; + } + + /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ + $classMapper = $this->ci->classMapper; + + // Get the user to delete + $user = $classMapper->getClassMapping('user')::where('user_name', $data['user_name']) + ->first(); + + return $user; + } } diff --git a/src/Database/Seeds/OrganisationPermissions.php b/src/Database/Seeds/OrganisationPermissions.php index 4983952..6b2dc3c 100644 --- a/src/Database/Seeds/OrganisationPermissions.php +++ b/src/Database/Seeds/OrganisationPermissions.php @@ -111,6 +111,12 @@ class OrganisationPermissions extends BaseSeed 'conditions' => 'always()', 'description' => 'Allows members to leave organisations.', ]), + 'join_organisation' => new Permission([ + 'slug' => 'join_organisation', + 'name' => 'Join organisation', + 'conditions' => 'always()', + 'description' => 'Allows members to join organisations.', + ]), 'delete_organisation' => new Permission([ 'slug' => 'delete_organisation', 'name' => 'Delete organisation', @@ -227,6 +233,7 @@ class OrganisationPermissions extends BaseSeed $permissions['uri_organisation_own']->id, $permissions['view_organisation_field_own']->id, $permissions['update_organisation_field_own']->id, + $permissions['join_organisation']->id, $permissions['leave_organisation']->id, $permissions['register_organisation']->id, ]); diff --git a/templates/modals/confirm-cancel-organisation-join.html.twig b/templates/modals/confirm-cancel-organisation-join.html.twig new file mode 100644 index 0000000..6a79333 --- /dev/null +++ b/templates/modals/confirm-cancel-organisation-join.html.twig @@ -0,0 +1,17 @@ +{% extends "modals/modal.html.twig" %} + +{% block modal_title %}{{translate("ORGANISATION.JOIN_REQUEST.CANCEL")}}{% endblock %} + +{% block modal_body %} +
+ {% include "forms/csrf.html.twig" %} +
+
+

{{translate("ORGANISATION.JOIN_REQUEST.CANCEL_CONFIRM", {name: organisation.name})}}{% if delete_hard %}
{{translate("ACTION_CANNOT_UNDONE")}}{% endif %}

+
+
+ + +
+
+{% endblock %}