From b64b4d72f945b5fa4373b53595af002625f748a3 Mon Sep 17 00:00:00 2001 From: Craig Williams Date: Thu, 10 Feb 2022 13:00:51 +0000 Subject: [PATCH] Organisation registration process implemented with configurable approval workflow --- assets/avsdev/js/pages/organisations.js | 3 + assets/avsdev/js/widgets/organisations.js | 39 + config/default.php | 26 + locale/en_US/messages.php | 22 +- routes/organisation-registration.php | 31 + routes/organisations.php | 1 - schema/requests/organisation/verify.yaml | 6 + src/Controller/OrganisationController.php | 4 +- .../OrganisationRegistrationController.php | 738 ++++++++++++++++++ .../v001/OrganisationApprovalsTable.php | 61 ++ .../v002/UpdateOrganisationApprovalsTable.php | 51 ++ src/Database/Models/Organisation.php | 3 + src/Database/Models/OrganisationApproval.php | 116 +++ .../Seeds/OrganisationPermissions.php | 15 + .../OrganisationApprovalRepository.php | 205 +++++ src/ServicesProvider/ServicesProvider.php | 16 + templates/forms/organisation.html.twig | 2 + .../organisation-approval-approved.html.twig | 16 + .../organisation-approval-rejected.html.twig | 16 + .../organisation-approval-request.html.twig | 46 ++ ...cancel-organisation-registration.html.twig | 17 + templates/pages/organisation.html.twig | 17 + templates/pages/organisations.html.twig | 9 +- 23 files changed, 1453 insertions(+), 7 deletions(-) create mode 100644 config/default.php create mode 100644 routes/organisation-registration.php create mode 100644 schema/requests/organisation/verify.yaml create mode 100644 src/Controller/OrganisationRegistrationController.php create mode 100644 src/Database/Migrations/v001/OrganisationApprovalsTable.php create mode 100644 src/Database/Migrations/v002/UpdateOrganisationApprovalsTable.php create mode 100644 src/Database/Models/OrganisationApproval.php create mode 100644 src/Repository/OrganisationApprovalRepository.php create mode 100644 templates/mail/organisation-approval-approved.html.twig create mode 100644 templates/mail/organisation-approval-rejected.html.twig create mode 100644 templates/mail/organisation-approval-request.html.twig create mode 100644 templates/modals/confirm-cancel-organisation-registration.html.twig diff --git a/assets/avsdev/js/pages/organisations.js b/assets/avsdev/js/pages/organisations.js index b78fc9a..2d1037d 100644 --- a/assets/avsdev/js/pages/organisations.js +++ b/assets/avsdev/js/pages/organisations.js @@ -17,6 +17,9 @@ $(document).ready(function() { // Bind creation button bindOrganisationCreationButton($("#widget-organisations")); + // Bind registration button + bindOrganisationRegistrationButton($("#widget-organisations")); + // Bind table buttons $("#widget-organisations").on("pagerComplete.ufTable", function () { bindOrganisationButtons($(this)); diff --git a/assets/avsdev/js/widgets/organisations.js b/assets/avsdev/js/widgets/organisations.js index c189251..e64db54 100644 --- a/assets/avsdev/js/widgets/organisations.js +++ b/assets/avsdev/js/widgets/organisations.js @@ -158,6 +158,31 @@ function bindOrganisationButtons(el, options) { }); }); + // Cancel a registration request + el.find('.js-organisation-cancelRegistration').click(function(e) { + e.preventDefault(); + + $("body").ufModal({ + sourceUrl: site.uri.public + "/modals/organisations/confirm-cancel-registration", + 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(); + }); + }); + }); + // Delete organisation button el.find('.js-organisation-delete').click(function(e) { e.preventDefault(); @@ -197,3 +222,17 @@ function bindOrganisationCreationButton(el) { attachOrganisationForm(); }); }; + +function bindOrganisationRegistrationButton(el) { + // Link create button + el.find('.js-organisation-register').click(function(e) { + e.preventDefault(); + + $("body").ufModal({ + sourceUrl: site.uri.public + "/modals/organisations/register", + msgTarget: $("#alerts-page") + }); + + attachOrganisationForm(); + }); +}; diff --git a/config/default.php b/config/default.php new file mode 100644 index 0000000..13a12cf --- /dev/null +++ b/config/default.php @@ -0,0 +1,26 @@ + [ + 'registration' => [ + 'require_approval' => true, + ], + 'approval' => [ + 'timeout' => -1, + ], + ], +]; \ No newline at end of file diff --git a/locale/en_US/messages.php b/locale/en_US/messages.php index a79a90a..471d88c 100644 --- a/locale/en_US/messages.php +++ b/locale/en_US/messages.php @@ -43,6 +43,14 @@ return [ 'MEMBER_COUNT' => '# Members (excl admins)', 'ADMIN_COUNT' => '# Admins', + 'REGISTER' => 'Register organisation', + 'REGISTRATION_SUCCESSFUL' => 'Successfully registered organisation {{name}}', + + 'CANCEL_REGISTRATION' => 'Cancel organisation registration', + 'CANCEL_REGISTRATION_CONFIRM' => 'Are you sure you want to cancel the registration request for organisation {{name}}?', + 'CANCEL_REGISTRATION_YES' => 'Yes, cancel organisation registration', + 'CANCEL_REGISTRATION_SUCCESSFUL' => 'Successfully cancelled registration of organisation {{name}}', + 'SELF' => 'My Organisations', 'MANAGE' => 'Manage Organisations', @@ -57,6 +65,12 @@ return [ 'SLUG' => [ 'IN_USE' => 'Organisation slug {{slug}} is already in use.', ], + 'APPROVAL' => [ + 'PENDING' => 'This organisation is pending approval!', + 'TOKEN_NOT_FOUND' => 'Approval token does not exist / Organisation is already approved/rejected.', + 'APPROVED' => 'You have successfully approved the organisation {{name}}.', + 'REJECTED' => 'You have successfully rejected the organisation {{name}}.', + ], ], 'MEMBER' => [ @@ -78,7 +92,9 @@ return [ 'SLUG_NOT_IN_USE' => 'A {{slug}} slug does not exist', 'LEAVE_CANNOT_UNDONE' => 'This action cannot be undone.', - - 'APPROVED' => 'Approved', - 'PENDING' => 'Pending', + 'CANCEL_REGISTRATION' => 'Cancel registration request', + 'APPROVED' => 'Approved', + 'PENDING' => 'Pending', + 'APPROVE' => 'Approve', + 'REJECT' => 'Reject', ]; diff --git a/routes/organisation-registration.php b/routes/organisation-registration.php new file mode 100644 index 0000000..d4310a0 --- /dev/null +++ b/routes/organisation-registration.php @@ -0,0 +1,31 @@ +group('/organisations', function () { + $this->get('/registration/approve', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationRegistrationController:approveToken'); + $this->get('/registration/reject', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationRegistrationController:rejectToken'); + + $this->post('/o/{slug}/registration/approve', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationRegistrationController:approve'); + $this->post('/o/{slug}/registration/reject', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationRegistrationController:reject'); +})->add('authGuard')->add(new NoCache()); + +$app->group('/api/organisations', function () { + $this->post('/register', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationRegistrationController:register'); + $this->delete('/o/{slug}/registration', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationRegistrationController:cancel'); +})->add('authGuard')->add(new NoCache()); + +$app->group('/modals/organisations', function () { + $this->get('/register', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationRegistrationController:getModalRegister'); + $this->get('/registration/confirm-cancel', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationRegistrationController:getModalConfirmCancel'); +})->add('authGuard')->add(new NoCache()); \ No newline at end of file diff --git a/routes/organisations.php b/routes/organisations.php index cf01c98..d59507a 100644 --- a/routes/organisations.php +++ b/routes/organisations.php @@ -47,4 +47,3 @@ $app->group('/modals/organisations', function () { })->add('authGuard')->add(new NoCache()); // TODO: add route for accepting members -// TODO: add route for verifying organisations diff --git a/schema/requests/organisation/verify.yaml b/schema/requests/organisation/verify.yaml new file mode 100644 index 0000000..01f3155 --- /dev/null +++ b/schema/requests/organisation/verify.yaml @@ -0,0 +1,6 @@ +--- +token: + validators: + required: + label: validation token + message: VALIDATION.REQUIRED diff --git a/src/Controller/OrganisationController.php b/src/Controller/OrganisationController.php index b915b27..8bf27b3 100644 --- a/src/Controller/OrganisationController.php +++ b/src/Controller/OrganisationController.php @@ -830,11 +830,13 @@ class OrganisationController extends SimpleController $editButtons['hidden'][] = 'delete'; } + $canAccessOrganisations = $authorizer->checkAccess($currentUser, 'uri_organisations'); + return $this->ci->view->render($response, 'pages/organisation.html.twig', [ 'organisation' => $organisation, 'fields' => $fields, 'tools' => $editButtons, - 'delete_redirect' => $this->ci->router->pathFor('uri_organisations'), + 'delete_redirect' => ($canAccessOrganisations ? $this->ci->router->pathFor('uri_organisations') : $this->ci->router->pathFor('dashboard')), 'leave_redirect' => $this->ci->router->pathFor('dashboard'), ]); } diff --git a/src/Controller/OrganisationRegistrationController.php b/src/Controller/OrganisationRegistrationController.php new file mode 100644 index 0000000..a70ca70 --- /dev/null +++ b/src/Controller/OrganisationRegistrationController.php @@ -0,0 +1,738 @@ +getParsedBody(); + + /** @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, 'register_organisation')) { + throw new ForbiddenException(); + } + + /** @var \UserFrosting\Sprinkle\Core\Alert\AlertStream $ms */ + $ms = $this->ci->alerts; + + /** @var \UserFrosting\Support\Repository\Repository $config */ + $config = $this->ci->config; + + // Load the request schema + $schema = new RequestSchema('schema://requests/organisation/create.yaml'); + + // Whitelist and set parameter defaults + $transformer = new RequestDataTransformer($schema); + $data = $transformer->transform($params); + + $error = false; + + // Validate request data + $validator = new ServerSideValidator($schema, $this->ci->translator); + if (!$validator->validate($data)) { + $ms->addValidationErrors($validator); + $error = true; + } + + /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ + $classMapper = $this->ci->classMapper; + + // Check if name or slug already exists + if ($classMapper->getClassMapping('organisation')::findUnique($data['name'], 'name')) { + $ms->addMessageTranslated('danger', 'ORGANISATION.NAME.IN_USE_REGISTER', $data); + $error = true; + } + + if ($error) { + return $response->withJson([], 400); + } + + $slugIncrement = 0; + $slugIncrementString = ''; + while ($classMapper->getClassMapping('organisation')::findUnique($data['slug'] . $slugIncrementString, 'slug')) { + $slugIncrement++; + $slugIncrementString = '_' . $slugIncrement; + } + $data['slug'] .= $slugIncrementString; + + $data['flag_approved'] = !$config['organisation.registration.require_approval']; + + // 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, $data, $ms, $currentUser, $config) { + // Create the organisation + $organisation = $classMapper->createInstance('organisation', $data); + + // Store new organisation to database + $organisation->save(); + + // Attach the members + $organisation->members()->attach($currentUser, ['flag_admin' => true]); + + // Save members + $organisation->save(); + + // Create activity record + $this->ci->userActivityLogger->info("User {$currentUser->user_name} registered organisation {$organisation->name}.", [ + 'type' => 'organisation_register', + 'user_id' => $currentUser->id, + ]); + + if ($config['organisation.registration.require_approval']) { + $timeout = $this->ci->config['organisation.approval.timeout']; + + // Try to generate a new approval request + $approval = $this->ci->repoOrganisationApproval->create($organisation, $currentUser, $timeout); + + $this->sendApprovalEmail($currentUser, $organisation, $approval->getToken()); + } + + $ms->addMessageTranslated('success', 'ORGANISATION.REGISTRATION_SUCCESSFUL', $data); + }); + + return $response->withJson([], 200); + } + + /** + * Processes the request to cancel an organisation registration request. + * + * Deletes the specified organisation (HARD!). + * Before doing so, checks that: + * 1. The user has permission to cancel this organisation registration request; + * 2. The submitted data is valid. + * This route requires authentication (and should generally be limited to admins or the root user). + * + * 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; + + // Access-controlled page + if (!$authorizer->checkAccess($currentUser, 'register_organisation', [ + 'organisation' => $organisation, + ])) { + throw new ForbiddenException(); + } + + if (!$authorizer->runCallback($currentUser, 'is_organisation_admin', $currentUser->id, $organisation->id)) { + throw new ForbiddenException(); + } + + /** @var \UserFrosting\Support\Repository\Repository $config */ + $config = $this->ci->config; + + /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ + $classMapper = $this->ci->classMapper; + + $organisationName = $organisation->name; + + // Begin transaction - DB will be rolled back if an exception occurs + Capsule::transaction(function () use ($organisation, $organisationName, $currentUser) { + $organisation->delete(true); + unset($organisation); + + // Create activity record + $this->ci->userActivityLogger->info("User {$currentUser->user_name} cancelled the request to register organisation {$organisationName}.", [ + 'type' => 'organisation_delete', + 'user_id' => $currentUser->id, + ]); + }); + + /** @var \UserFrosting\Sprinkle\Core\Alert\AlertStream $ms */ + $ms = $this->ci->alerts; + + $ms->addMessageTranslated('success', 'ORGANISATION.CANCEL_REGISTRATION_SUCCESSFUL', [ + 'name' => $organisationName, + ]); + + return $response->withJson([], 200); + } + + /** + * Approves an organisation registration request. + * + * Processes the request from the organisation page, checking that: + * 1. The organisation exists; + * 2. The organisation is not already approved; + * This route requires authorization. + * + * AuthGuard: true + * Route: /organisations/o/{slug}/registration/approve + * Route Name: {none} + * Request type: GET + * + * @param Request $request + * @param Response $response + * @param array $args + */ + public function approve(Request $request, Response $response, $args) + { + /** @var \UserFrosting\Sprinkle\Core\Alert\AlertStream $ms */ + $ms = $this->ci->alerts; + + /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ + $classMapper = $this->ci->classMapper; + + /** @var \UserFrosting\Support\Repository\Repository $config */ + $config = $this->ci->config; + + /** @var \UserFrosting\Sprinkle\Account\Authorize\AuthorizationManager $authorizer */ + $authorizer = $this->ci->authorizer; + + /** @var \UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface $currentUser */ + $currentUser = $this->ci->currentUser; + + $organisation = $this->getOrganisationFromParams($args); + + // If the organisation doesn't exist, return 404 + if (!$organisation) { + throw new NotFoundException(); + } + + // Access-controlled page + if (!$authorizer->checkAccess($currentUser, 'approve_organisation')) { + throw new ForbiddenException(); + } + + $verification = $this->ci->repoOrganisationApproval->completeWithoutToken($organisation, $currentUser, ['approved' => true]); + + $requester = $classMapper->getClassMapping('user')::find($verification->requester_id); + + $this->sendApprovedEmail($organisation, $requester); + + $ms->addMessageTranslated('success', 'ORGANISATION.APPROVAL.APPROVED', [ + 'name' => $organisation->name + ]); + + return $response->withRedirect($this->ci->router->pathFor('uri_organisation', ['slug' => $organisation->slug])); + } + + /** + * Approves an organisation registration request. + * + * Processes the request from the email verification link that was emailed to the organisation administrators, checking that: + * 1. The token provided matches an organisation in the database; + * 2. The organisation is not already approved; + * This route requires authorization. + * + * AuthGuard: true + * Route: /organisations/o/{slug}/approve + * Route Name: {none} + * Request type: GET + * + * @param Request $request + * @param Response $response + * @param array $args + */ + public function approveToken(Request $request, Response $response, $args) + { + /** @var \UserFrosting\Sprinkle\Core\Alert\AlertStream $ms */ + $ms = $this->ci->alerts; + + /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ + $classMapper = $this->ci->classMapper; + + /** @var \UserFrosting\Support\Repository\Repository $config */ + $config = $this->ci->config; + + /** @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')) { + throw new ForbiddenException(); + } + + // GET parameters + $params = $request->getQueryParams(); + + // Load request schema + $schema = new RequestSchema('schema://requests/organisation/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')); + } + + $verification = $this->ci->repoOrganisationApproval->complete($data['token'], $currentUser, ['approved' => true]); + + if (!$verification) { + $ms->addMessageTranslated('danger', 'ORGANISATION.APPROVAL.TOKEN_NOT_FOUND'); + return $response->withRedirect($this->ci->router->pathFor('dashboard')); + } + + $organisation = $classMapper->getClassMapping('organisation')::find($verification->organisation_id); + $requester = $classMapper->getClassMapping('user')::find($verification->requester_id); + + $this->sendApprovedEmail($organisation, $requester); + + $ms->addMessageTranslated('success', 'ORGANISATION.APPROVAL.APPROVED', [ + 'name' => $organisation->name + ]); + + // Forward to login page + return $response->withRedirect($this->ci->router->pathFor('dashboard')); + } + + /** + * Rejects an organisation registration request. + * + * Processes the request from the email verification link that was emailed to the organisation administrators, checking that: + * 1. The token provided matches an organisation in the database; + * 2. The organisation is not already approved; + * This route requires authorization. + * + * AuthGuard: true + * Route: /organisations/o/{slug}/reject + * Route Name: {none} + * Request type: GET + * + * @param Request $request + * @param Response $response + * @param array $args + */ + public function reject(Request $request, Response $response, $args) + { + /** @var \UserFrosting\Sprinkle\Core\Alert\AlertStream $ms */ + $ms = $this->ci->alerts; + + /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ + $classMapper = $this->ci->classMapper; + + /** @var \UserFrosting\Support\Repository\Repository $config */ + $config = $this->ci->config; + + /** @var \UserFrosting\Sprinkle\Account\Authorize\AuthorizationManager $authorizer */ + $authorizer = $this->ci->authorizer; + + /** @var \UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface $currentUser */ + $currentUser = $this->ci->currentUser; + + $organisation = $this->getOrganisationFromParams($args); + + // If the organisation doesn't exist, return 404 + if (!$organisation) { + throw new NotFoundException(); + } + + // Access-controlled page + if (!$authorizer->checkAccess($currentUser, 'approve_organisation')) { + throw new ForbiddenException(); + } + + $verification = $this->ci->repoOrganisationApproval->completeWithoutToken($organisation, $currentUser, ['approved' => false]); + + $requester = $classMapper->getClassMapping('user')::find($verification->requester_id); + + $this->sendRejectedEmail($organisation, $requester); + + $organisation->delete(); + + $ms->addMessageTranslated('success', 'ORGANISATION.APPROVAL.REJECTED', [ + 'name' => $organisation->name + ]); + + return $response->withRedirect($this->ci->router->pathFor('uri_organisations')); + } + + /** + * Rejects an organisation registration request. + * + * Processes the request from the email verification link that was emailed to the organisation administrators, checking that: + * 1. The token provided matches an organisation in the database; + * 2. The organisation is not already approved; + * This route requires authorization. + * + * AuthGuard: true + * Route: /organisations/o/{slug}/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\Core\Alert\AlertStream $ms */ + $ms = $this->ci->alerts; + + /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ + $classMapper = $this->ci->classMapper; + + /** @var \UserFrosting\Support\Repository\Repository $config */ + $config = $this->ci->config; + + /** @var \UserFrosting\Sprinkle\Account\Authorize\AuthorizationManager $authorizer */ + $authorizer = $this->ci->authorizer; + + /** @var \UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface $currentUser */ + $currentUser = $this->ci->currentUser; + + + $dashboardPage = $this->ci->router->pathFor('dashboard'); + + // Access-controlled page + if (!$authorizer->checkAccess($currentUser, 'approve_organisation')) { + throw new ForbiddenException(); + } + + // GET parameters + $params = $request->getQueryParams(); + + // Load request schema + $schema = new RequestSchema('schema://requests/organisation/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($dashboardPage); + } + + $verification = $this->ci->repoOrganisationApproval->complete($data['token'], $currentUser, ['approved' => false]); + + if ($verification === false) { + $ms->addMessageTranslated('danger', 'ORGANISATION.APPROVAL.TOKEN_NOT_FOUND'); + + return $response->withRedirect($dashboardPage); + } + + $organisation = $classMapper->getClassMapping('organisation')::find($verification->organisation_id); + $requester = $classMapper->getClassMapping('user')::find($verification->requester_id); + + $this->sendRejectedEmail($organisation, $requester); + + $ms->addMessageTranslated('success', 'ORGANISATION.APPROVAL.REJECTED', [ + 'name' => $organisation->name + ]); + + // Forward to login page + return $response->withRedirect($dashboardPage); + } + + + /** + * Renders the modal form for registering a new organisation. + * + * This does NOT render a complete page. Instead, it renders the HTML for the modal, which can be embedded in other pages. + * This page requires authentication. + * + * Request type: GET + * + * @param Request $request + * @param Response $response + * @param array $args + * + * @throws ForbiddenException If user is not authorized to access page + */ + public function getModalRegister(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; + + /** @var \UserFrosting\I18n\Translator $translator */ + $translator = $this->ci->translator; + + // Access-controlled page + if (!$authorizer->checkAccess($currentUser, 'register_organisation')) { + throw new ForbiddenException(); + } + + /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ + $classMapper = $this->ci->classMapper; + + // Create a dummy organisation to prepopulate fields + $organisation = $classMapper->createInstance('organisation', []); + + $fieldNames = ['name', 'slug', 'description']; + $fields = [ + 'hidden' => ['slug'], + 'disabled' => [], + ]; + + // Load validation rules + $schema = new RequestSchema('schema://requests/organisation/create.yaml'); + $validator = new JqueryValidationAdapter($schema, $this->ci->translator); + + return $this->ci->view->render($response, 'modals/organisation.html.twig', [ + 'organisation' => $organisation, + 'form' => [ + 'action' => 'api/organisations/register', + 'method' => 'POST', + 'fields' => $fields, + 'submit_text' => $translator->translate('REGISTER'), + ], + 'page' => [ + 'validators' => $validator->rules('json', false), + ], + ]); + } + + /** + * Get cancel registration 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, 'register_organisation', [ + 'organisation' => $organisation, + ])) { + throw new ForbiddenException(); + } + + // Access-controlled page + if (!$authorizer->runCallback($currentUser, 'is_organisation_admin', $currentUser->id, $organisation->id)) { + throw new ForbiddenException(); + } + + /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ + $classMapper = $this->ci->classMapper; + + return $this->ci->view->render($response, 'modals/confirm-cancel-organisation-registration.html.twig', [ + 'organisation' => $organisation, + 'form' => [ + 'action' => "api/organisations/o/{$organisation->slug}/registration", + ], + ]); + } + + + /** + * 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.approval.timeout']; + + // Create and send approval email + $message = new TwigMailMessage($this->ci->view, 'mail/organisation-approval-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), + ]); + + $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(); + } + + $recipients = $role->users()->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 sendApprovedEmail(OrganisationInterface $organisation, UserInterface $requester) + { + $message = new TwigMailMessage($this->ci->view, 'mail/organisation-approval-approved.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(OrganisationInterface $organisation, UserInterface $requester) + { + $message = new TwigMailMessage($this->ci->view, 'mail/organisation-approval-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); + } + + + /** + * Get organisation from params. + * + * @param array $params + * + * @throws BadRequestException + * + * @return Organisation + */ + protected function getOrganisationFromParams($params) + { + // Load the request schema + $schema = new RequestSchema('schema://requests/organisation/get-by-slug.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 organisation + $organisation = $classMapper->getClassMapping('organisation')::where('slug', $data['slug']) + ->first(); + + return $organisation; + } +} diff --git a/src/Database/Migrations/v001/OrganisationApprovalsTable.php b/src/Database/Migrations/v001/OrganisationApprovalsTable.php new file mode 100644 index 0000000..72c36be --- /dev/null +++ b/src/Database/Migrations/v001/OrganisationApprovalsTable.php @@ -0,0 +1,61 @@ +schema->hasTable('organisation_approvals')) { + $this->schema->create('organisation_approvals', function (Blueprint $table) { + $table->increments('id'); + $table->integer('requester_id')->unsigned(); + $table->integer('organisation_id')->unsigned(); + $table->string('hash'); + $table->boolean('completed')->default(0); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->integer('approver_id')->unsigned(); + $table->timestamps(); + + $table->engine = 'InnoDB'; + $table->collation = 'utf8_unicode_ci'; + $table->charset = 'utf8'; + $table->foreign('requester_id')->references('id')->on('users'); + $table->foreign('approver_id')->references('id')->on('users'); + $table->foreign('organisation_id')->references('id')->on('organisations'); + $table->index('requester_id'); + $table->index('approver_id'); + $table->index('hash'); + }); + } + } + + /** + * {@inheritdoc} + */ + public function down() + { + $this->schema->drop('organisation_approvals'); + } +} diff --git a/src/Database/Migrations/v002/UpdateOrganisationApprovalsTable.php b/src/Database/Migrations/v002/UpdateOrganisationApprovalsTable.php new file mode 100644 index 0000000..97f5290 --- /dev/null +++ b/src/Database/Migrations/v002/UpdateOrganisationApprovalsTable.php @@ -0,0 +1,51 @@ +schema->hasTable('organisation_approvals')) { + $this->schema->table('organisation_approvals', function (Blueprint $table) { + $table->integer('approver_id')->unsigned()->nullable()->change(); + }); + } + } + + /** + * {@inheritdoc} + */ + public function down() + { + $this->schema->table('organisations', function (Blueprint $table) { + $table->integer('approver_id')->unsigned()->change(); + }); + } +} diff --git a/src/Database/Models/Organisation.php b/src/Database/Models/Organisation.php index e7acf80..e3cb647 100644 --- a/src/Database/Models/Organisation.php +++ b/src/Database/Models/Organisation.php @@ -151,6 +151,9 @@ class Organisation extends Model implements OrganisationInterface if ($hardDelete) { static::$ci->get('organisation.beforeDelete')($this); + // Remove all organisation tokens + $classMapper->getClassMapping('organisation_approval')::where('organisation_id', $this->id)->delete(); + // Remove all member associations $this->members()->detach(); diff --git a/src/Database/Models/OrganisationApproval.php b/src/Database/Models/OrganisationApproval.php new file mode 100644 index 0000000..32bbaf9 --- /dev/null +++ b/src/Database/Models/OrganisationApproval.php @@ -0,0 +1,116 @@ +token; + } + + /** + * @param string $value + * + * @return self + */ + public function setToken($value) + { + $this->token = $value; + + return $this; + } + + /** + * Get the user associated with this request of this approval. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function requester() + { + /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ + $classMapper = static::$ci->classMapper; + + return $this->belongsTo($classMapper->getClassMapping('user'), 'requester_id'); + } + + /** + * Get the organisation associated with this approval request. + * + * @return \Illuminate\Database\Eloquent\Relations\belongsTo + */ + public function organisation() + { + /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ + $classMapper = static::$ci->classMapper; + + return $this->belongsTo($classMapper->getClassMapping('organisation'), 'organisation_id'); + } + + /** + * Get the user associated with this approval or rejection of this request. + * + * @return \Illuminate\Database\Eloquent\Relations\belongsTo + */ + public function approver() + { + /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ + $classMapper = static::$ci->classMapper; + + return $this->belongsTo($classMapper->getClassMapping('user'), 'approver_id'); + } +} diff --git a/src/Database/Seeds/OrganisationPermissions.php b/src/Database/Seeds/OrganisationPermissions.php index 66c8e0f..a703aa3 100644 --- a/src/Database/Seeds/OrganisationPermissions.php +++ b/src/Database/Seeds/OrganisationPermissions.php @@ -51,6 +51,12 @@ class OrganisationPermissions extends BaseSeed 'conditions' => 'always()', 'description' => 'Create a new organisation.', ]), + 'register_organisation' => new Permission([ + 'slug' => 'register_organisation', + 'name' => 'Register organisation', + 'conditions' => 'always()', + 'description' => 'Register a new organisation. May optionally require approval.', + ]), 'view_organisation_field' => new Permission([ 'slug' => 'view_organisation_field', 'name' => 'View organisation', @@ -69,6 +75,12 @@ class OrganisationPermissions extends BaseSeed 'conditions' => 'always()', 'description' => 'Edit basic properties of any organisation.', ]), + 'approve_organisation' => new Permission([ + 'slug' => 'approve_organisation', + 'name' => 'Approve/Reject organisation', + 'conditions' => 'always()', + 'description' => 'Approve/Reject organisation registation request.', + ]), 'merge_organisations' => new Permission([ 'slug' => 'merge_organisations', 'name' => 'Merge two organisations', @@ -143,6 +155,7 @@ class OrganisationPermissions extends BaseSeed $permissions['create_organisation']->id, $permissions['view_organisation_field']->id, $permissions['update_organisation_field']->id, + $permissions['approve_organisation']->id, $permissions['merge_organisations']->id, $permissions['delete_organisation']->id, $permissions['uri_organisations']->id, @@ -157,6 +170,7 @@ class OrganisationPermissions extends BaseSeed $permissions['create_organisation']->id, $permissions['view_organisation_field']->id, $permissions['update_organisation_field']->id, + $permissions['approve_organisation']->id, $permissions['merge_organisations']->id, $permissions['delete_organisation']->id, $permissions['uri_organisations']->id, @@ -170,6 +184,7 @@ class OrganisationPermissions extends BaseSeed $permissions['uri_organisation_own']->id, $permissions['view_organisation_field_own']->id, $permissions['leave_organisation']->id, + $permissions['register_organisation']->id, ]); } } diff --git a/src/Repository/OrganisationApprovalRepository.php b/src/Repository/OrganisationApprovalRepository.php new file mode 100644 index 0000000..4354409 --- /dev/null +++ b/src/Repository/OrganisationApprovalRepository.php @@ -0,0 +1,205 @@ +algorithm, $token); + + // Find an unexpired, incomplete token for the specified hash + $model = $this->classMapper->getClassMapping($this->modelIdentifier)::where('hash', $hash) + ->where('completed', false) + ->where(function($query) { + return $query->where('expires_at', '>', Carbon::now())->orWhereNull('expires_at'); + }) + ->first(); + + if ($model === null) { + return false; + } + + // Fetch user for this token + $organisation = $this->classMapper->getClassMapping('organisation')::find($model->organisation_id); + $requester = $this->classMapper->getClassMapping('user')::find($model->requester_id); + + if (!$organisation || !$requester) { + return false; + } + + // Begin transaction - DB will be rolled back if an exception occurs + Capsule::transaction(function () use ($model, $organisation, $requester, $approver, $params) { + $this->updateOrganisation($organisation, $requester, $approver, $params); + + $model->fill([ + 'completed' => true, + 'completed_at' => Carbon::now(), + ]); + + $model->approver_id = $approver->id; + + $model->save(); + }); + + return $model; + } + + /** + * Completes a token request without requiring the token (admin overrride) + */ + public function completeWithoutToken(OrganisationInterface $organisation, UserInterface $approver, $params = []) + { + $model = $this->classMapper->getClassMapping($this->modelIdentifier)::where('organisation_id', $organisation->id) + ->where('completed', false) + ->first(); + + if ($model === null) { + return false; + } + + // Fetch user for this token + $requester = $this->classMapper->getClassMapping('user')::find($model->requester_id); + + if (!$requester) { + return false; + } + + // Begin transaction - DB will be rolled back if an exception occurs + Capsule::transaction(function () use ($model, $organisation, $requester, $approver, $params) { + $this->updateOrganisation($organisation, $requester, $approver, $params); + + $model->fill([ + 'completed' => true, + 'completed_at' => Carbon::now(), + ]); + + $model->approver_id = $approver->id; + + $model->save(); + }); + + return $model; + } + + /** + * {@inheritdoc} + */ + public function create(OrganisationInterface $organisation, UserInterface $requester, $timeout) + { + // Remove any previous tokens for this organisation + $this->removeExisting($organisation); + + // Compute expiration time + $expiresAt = Carbon::now()->addSeconds($timeout); + + $model = $this->classMapper->createInstance($this->modelIdentifier); + + // Generate a random token + $model->setToken($this->generateRandomToken()); + + // Hash the password reset token for the stored version + $hash = hash($this->algorithm, $model->getToken()); + + $model->fill([ + 'hash' => $hash, + 'completed' => false, + 'expires_at' => ($timeout >= 0 ? $expiresAt : null), + ]); + + $model->organisation_id = $organisation->id; + $model->requester_id = $requester->id; + + $model->save(); + + return $model; + } + + /** + * {@inheritdoc} + */ + public function exists(OrganisationInterface $organisation, UserInterface $requester = null, $token = null) + { + $model = $this->classMapper->getClassMapping($this->modelIdentifier)::where('organisation_id', $organisation->id) + ->where('completed', false) + ->where(function($query) { + return $query->where('expires_at', '>', Carbon::now())->orWhereNull('expires_at'); + }); + + if ($token) { + // get token hash + $hash = hash($this->algorithm, $token); + $model->where('hash', $hash); + } + + if ($requester) { + $model->where('requester_id', $requester->id); + } + + return $model->first() ?: false; + } + + /** + * {@inheritdoc} + */ + protected function removeExisting(OrganisationInterface $organisation, UserInterface $requester = null) + { + $model = $this->classMapper->getClassMapping($this->modelIdentifier)::where('organisation_id', $organisation->id); + + if ($requester) { + $model->where('requester_id', $requester->id); + } + + return $model->delete(); + } + + + /** + * {@inheritdoc} + */ + protected function updateOrganisation(OrganisationInterface $organisation, UserInterface $requester, UserInterface $approver, $args) + { + if ($args['approved']) { + $organisation->flag_approved = 1; + $organisation->save(); + } + } + + /** + * Overridden + */ + protected function updateUser(UserInterface $user, $args) + { + return false; + } +} diff --git a/src/ServicesProvider/ServicesProvider.php b/src/ServicesProvider/ServicesProvider.php index 46d58ce..79d736e 100644 --- a/src/ServicesProvider/ServicesProvider.php +++ b/src/ServicesProvider/ServicesProvider.php @@ -15,6 +15,7 @@ use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use UserFrosting\Sprinkle\Organisations\Database\Models\Interfaces\OrganisationInterface; use UserFrosting\Sprinkle\Organisations\Twig\OrganisationsExtension; +use UserFrosting\Sprinkle\Organisations\Repository\OrganisationApprovalRepository; use UserFrosting\Sprinkle\Organisations\Authorize\AuthorizationManager; @@ -41,6 +42,7 @@ class ServicesProvider */ $container->extend('classMapper', function ($classMapper, $c) { $classMapper->setClassMapping('organisation', 'UserFrosting\Sprinkle\Organisations\Database\Models\Organisation'); + $classMapper->setClassMapping('organisation_approval', 'UserFrosting\Sprinkle\Organisations\Database\Models\OrganisationApproval'); $classMapper->setClassMapping('organisation_sprunje', 'UserFrosting\Sprinkle\Organisations\Sprunje\OrganisationSprunje'); $classMapper->setClassMapping('user', 'UserFrosting\Sprinkle\Organisations\Database\Models\User'); $classMapper->setClassMapping('user_sprunje', 'UserFrosting\Sprinkle\Organisations\Sprunje\UserSprunje'); @@ -138,5 +140,19 @@ class ServicesProvider }; }; + + /* + * Repository for approval requests. + * + * @return \UserFrosting\Sprinkle\Organisations\Repository\OrganisationApprovalRepository + */ + $container['repoOrganisationApproval'] = function ($c) { + $classMapper = $c->classMapper; + $config = $c->config; + + $repo = new OrganisationApprovalRepository($classMapper, $config['verification.algorithm']); + + return $repo; + }; } } diff --git a/templates/forms/organisation.html.twig b/templates/forms/organisation.html.twig index ec48337..99b5e58 100644 --- a/templates/forms/organisation.html.twig +++ b/templates/forms/organisation.html.twig @@ -32,6 +32,8 @@ + {% else %} + {% endif %} {% if 'description' not in form.fields.hidden %}
diff --git a/templates/mail/organisation-approval-approved.html.twig b/templates/mail/organisation-approval-approved.html.twig new file mode 100644 index 0000000..8f7b34c --- /dev/null +++ b/templates/mail/organisation-approval-approved.html.twig @@ -0,0 +1,16 @@ +{% block subject %} + {{site.title}} - organisation registration approved +{% endblock %} + +{% block body %} +

+ Dear {{recipient.first_name}}, +

+

+ The organisation registration request you submitted at {{site.title}} ({{site.uri.public}}) has been approved. +

+

+ With regards,
+ The {{site.title}} Team +

+{% endblock %} \ No newline at end of file diff --git a/templates/mail/organisation-approval-rejected.html.twig b/templates/mail/organisation-approval-rejected.html.twig new file mode 100644 index 0000000..8135d67 --- /dev/null +++ b/templates/mail/organisation-approval-rejected.html.twig @@ -0,0 +1,16 @@ +{% block subject %} + {{site.title}} - organisation registration rejected +{% endblock %} + +{% block body %} +

+ Dear {{recipient.first_name}}, +

+

+ The organisation registration request you submitted at {{site.title}} ({{site.uri.public}}) has been rejected. +

+

+ With regards,
+ The {{site.title}} Team +

+{% endblock %} \ No newline at end of file diff --git a/templates/mail/organisation-approval-request.html.twig b/templates/mail/organisation-approval-request.html.twig new file mode 100644 index 0000000..5058d97 --- /dev/null +++ b/templates/mail/organisation-approval-request.html.twig @@ -0,0 +1,46 @@ +{% block subject %} + {{site.title}} - new organisation requires approval +{% endblock %} + +{% block body %} +

+ Dear {{recipient.first_name}}, +

+

+ Someone has created a new organisation which requires approving at {{site.title}} ({{site.uri.public}}). +

+

+ The organisation details are: +

+
Requester name:
+
{{requester.full_name}}
+ +
Requester email:
+
{{requester.email}}
+ +
Organisation name:
+
{{organisation.name}}
+ +
Organisation description:
+
{{organisation.description}}
+ +

+

+ You may verify or reject this organisation via the control dashboard ({{site.uri.public}}/organisations). +

+

+ To verify this organisation immediately you may do so by visiting: {{site.uri.public}}/organisations/approve?token={{token}}. +

+

+ To reject this organisation immediately you may do so by visiting: {{site.uri.public}}/organisations/reject?token={{token}}. +

+{% if approval_expiration %} +

+ The approcal period for this organisation will expire in {{approval_expiration}} at which point the organisation will be automatically rejected. +

+{% endif %} +

+ With regards,
+ The {{site.title}} Team +

+{% endblock %} \ No newline at end of file diff --git a/templates/modals/confirm-cancel-organisation-registration.html.twig b/templates/modals/confirm-cancel-organisation-registration.html.twig new file mode 100644 index 0000000..eec521f --- /dev/null +++ b/templates/modals/confirm-cancel-organisation-registration.html.twig @@ -0,0 +1,17 @@ +{% extends "modals/modal.html.twig" %} + +{% block modal_title %}{{translate("ORGANISATION.CANCEL_REGISTRATION")}}{% endblock %} + +{% block modal_body %} +
+ {% include "forms/csrf.html.twig" %} +
+
+

{{translate("ORGANISATION.CANCEL_REGISTRATION_CONFIRM", {name: organisation.name})}}{% if delete_hard %}
{{translate("DELETE_CANNOT_UNDONE")}}{% endif %}

+
+
+ + +
+
+{% endblock %} diff --git a/templates/pages/organisation.html.twig b/templates/pages/organisation.html.twig index af01076..e07f1e6 100644 --- a/templates/pages/organisation.html.twig +++ b/templates/pages/organisation.html.twig @@ -50,6 +50,23 @@ {{organisation.description}}

{% endif %} + {% if organisation.flag_approved != 1 %} +
+

+ {{ translate('ORGANISATION.APPROVAL.PENDING') }} +

+
+ {% if checkAccess('approve_organisation') %} +
+ {% include "forms/csrf.html.twig" %} + + +
+ {% elseif isOrganisationAdmin(organisation) %} + + {% endif %} +
+ {% endif %} {% if 'members' not in fields.hidden %}
{{ translate('ADMIN', 2)}} diff --git a/templates/pages/organisations.html.twig b/templates/pages/organisations.html.twig index 8859213..11222e3 100644 --- a/templates/pages/organisations.html.twig +++ b/templates/pages/organisations.html.twig @@ -26,13 +26,18 @@ } %}
- {% if checkAccess('create_organisation') %} {% endif %} + {% if checkAccess('register_organisation') %} + + {% endif %} +