From 57dfed304fdce52fecc5a97a5fc4e0296afc91d4 Mon Sep 17 00:00:00 2001 From: Craig Williams Date: Thu, 10 Feb 2022 15:52:57 +0000 Subject: [PATCH] Added functionality to permenently delete or restore deleted organisations --- assets/avsdev/js/pages/organisations.js | 35 ++- assets/avsdev/js/widgets/organisations.js | 88 ++++++ locale/en_US/messages.php | 15 + routes/organisations.php | 12 + src/Controller/OrganisationController.php | 271 +++++++++++++++++- .../Seeds/OrganisationPermissions.php | 22 ++ ...rm-permenent-delete-organisation.html.twig | 17 ++ .../pages/deleted-organisations.html.twig | 50 ++++ templates/pages/organisations.html.twig | 6 +- .../tables/deleted-organisations.html.twig | 104 +++++++ 10 files changed, 603 insertions(+), 17 deletions(-) create mode 100644 templates/modals/confirm-permenent-delete-organisation.html.twig create mode 100644 templates/pages/deleted-organisations.html.twig create mode 100644 templates/tables/deleted-organisations.html.twig diff --git a/assets/avsdev/js/pages/organisations.js b/assets/avsdev/js/pages/organisations.js index 2d1037d..643419f 100644 --- a/assets/avsdev/js/pages/organisations.js +++ b/assets/avsdev/js/pages/organisations.js @@ -8,20 +8,31 @@ */ $(document).ready(function() { + if ($("#widget-organisations").length) { + $("#widget-organisations").ufTable({ + dataUrl: site.uri.public + "/api/organisations", + useLoadingTransition: site.uf_table.use_loading_transition + }); - $("#widget-organisations").ufTable({ - dataUrl: site.uri.public + "/api/organisations", - useLoadingTransition: site.uf_table.use_loading_transition - }); + // Bind creation button + bindOrganisationCreationButton($("#widget-organisations")); - // Bind creation button - bindOrganisationCreationButton($("#widget-organisations")); + // Bind registration button + bindOrganisationRegistrationButton($("#widget-organisations")); - // Bind registration button - bindOrganisationRegistrationButton($("#widget-organisations")); + // Bind table buttons + $("#widget-organisations").on("pagerComplete.ufTable", function () { + bindOrganisationButtons($(this)); + }); + } else { + $("#widget-deleted-organisations").ufTable({ + dataUrl: site.uri.public + "/api/organisations/deleted", + useLoadingTransition: site.uf_table.use_loading_transition + }); - // Bind table buttons - $("#widget-organisations").on("pagerComplete.ufTable", function () { - bindOrganisationButtons($(this)); - }); + // Bind table buttons + $("#widget-deleted-organisations").on("pagerComplete.ufTable", function () { + bindOrganisationButtons($(this)); + }); + } }); diff --git a/assets/avsdev/js/widgets/organisations.js b/assets/avsdev/js/widgets/organisations.js index 80c6413..0c02fe3 100644 --- a/assets/avsdev/js/widgets/organisations.js +++ b/assets/avsdev/js/widgets/organisations.js @@ -88,6 +88,47 @@ function approveOrganisation(slug, approve, options) { }); } +/** + * Restore a deleted organisation + */ +function restoreOrganisation(slug, options) { + 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/' + slug + '/restore'; + var debugAjax = (typeof site !== "undefined") && site.debug.ajax; + + return $.ajax({ + type: "PUT", + 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(); + }); +} + /** * Link organisation action buttons, for example in a table or on a specific organisation's page. * @param {module:jQuery} el jQuery wrapped element to target. @@ -263,6 +304,53 @@ function bindOrganisationButtons(el, options) { }); }); }); + + + // View the deleted organisations page + el.find('.js-organisation-viewDeleted').click(function(e) { + e.preventDefault(); + + window.location.href = site.uri.public + '/organisations/deleted'; + }); + + // Permenetly delete organisation button + el.find('.js-organisation-permenentDelete').click(function(e) { + e.preventDefault(); + + $("body").ufModal({ + sourceUrl: site.uri.public + "/modals/organisations/confirm-permenent-delete", + 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(); + }); + }); + }); + + // Permenetly delete organisation button + el.find('.js-organisation-restore').click(function(e) { + e.preventDefault(); + + restoreOrganisation($(this).data('slug'), options); + }); + + // Return from the deleted organisations page + el.find('.js-organisation-return').click(function(e) { + e.preventDefault(); + + window.location.href = site.uri.public + '/organisations'; + }); } function bindOrganisationCreationButton(el) { diff --git a/locale/en_US/messages.php b/locale/en_US/messages.php index 8250c75..2d11e60 100644 --- a/locale/en_US/messages.php +++ b/locale/en_US/messages.php @@ -18,6 +18,7 @@ return [ 2 => 'Organisations', 'PAGE_DESCRIPTION' => 'A listing of the organisations for your site. Provides management tools for editing and deleting organisations.', + 'DELETED_PAGE_DESCRIPTION' => 'A listing of the deleted organisations for your site. Provides management tools for restoring and permenently deleting organisations.', 'INFO_PAGE' => 'Organisation information page for {{name}}', 'SUMMARY' => 'Organisation Summary', @@ -56,6 +57,13 @@ return [ 'MANAGE' => 'Manage Organisations', 'ASSIGN_NEW' => 'Assign to organisation', + 'RESTORE_DELETED' => 'Restore organisation', + 'RESTORE_SUCCESSFUL' => 'Successfully restored organistion {{name}}', + 'PERMENENT_DELETE' => 'Permenetly delete organisation', + 'PERMENENT_DELETE_CONFIRM' => 'Are you sure you want to permenently delete the organisation {{name}}?', + 'PERMENENT_DELETE_YES' => 'Yes, permenently delete organisation', + 'PERMENENT_DELETION_SUCCESSFUL' => 'Successfully permenently deleted organisation {{name}}', + 'NAME' => [ 1 => 'Organisation name', @@ -101,4 +109,11 @@ return [ 'PENDING' => 'Pending', 'APPROVE' => 'Approve', 'REJECT' => 'Reject', + + 'VIEW_DELETED' => 'View deleted', + 'DELETED' => 'Deleted', + + 'RETURN' => 'Return', + + 'ACTION_CANNOT_UNDONE' => 'This action cannot be undone!', ]; diff --git a/routes/organisations.php b/routes/organisations.php index d59507a..1ed6921 100644 --- a/routes/organisations.php +++ b/routes/organisations.php @@ -18,6 +18,8 @@ $app->group('/organisations', function () { $this->get('/o/{slug}', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationController:pageInfo') ->setName('uri_organisation'); + + $this->get('/deleted', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationController:pageListDeleted'); })->add('authGuard')->add(new NoCache()); $app->group('/api/organisations', function () { @@ -34,6 +36,13 @@ $app->group('/api/organisations', function () { $this->post('/merge', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationController:merge'); + + + $this->get('/deleted', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationController:getListDeleted'); + + $this->put('/o/{slug}/restore', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationController:restore'); + + $this->delete('/o/{slug}/permenent', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationController:deletePermenent'); })->add('authGuard')->add(new NoCache()); $app->group('/modals/organisations', function () { @@ -44,6 +53,9 @@ $app->group('/modals/organisations', function () { $this->get('/merge', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationController:getModalMerge'); $this->get('/confirm-delete', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationController:getModalConfirmDelete'); + + + $this->get('/confirm-permenent-delete', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationController:getModalConfirmPermenentDelete'); })->add('authGuard')->add(new NoCache()); // TODO: add route for accepting members diff --git a/src/Controller/OrganisationController.php b/src/Controller/OrganisationController.php index 8bf27b3..860f7c1 100644 --- a/src/Controller/OrganisationController.php +++ b/src/Controller/OrganisationController.php @@ -474,6 +474,145 @@ class OrganisationController extends SimpleController } + /** + * Processes the request to permenently delete a deleted organisation. + * + * Permenently deletes the specified organisation. + * Before doing so, checks that: + * 1. The user has permission to delete this organisation; + * 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 deletePermenent(Request $request, Response $response, $args) + { + $organisation = $this->getOrganisationFromParams($args, true); + + // 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, 'permenent_delete_organisation', [ + 'organisation' => $organisation, + ])) { + 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} permenetly deleted organisation {$organisationName}.", [ + 'type' => 'organisation_delete', + 'user_id' => $currentUser->id, + ]); + }); + + /** @var \UserFrosting\Sprinkle\Core\Alert\AlertStream $ms */ + $ms = $this->ci->alerts; + + $ms->addMessageTranslated('success', 'ORGANISATION.PERMENENT_DELETION_SUCCESSFUL', [ + 'name' => $organisationName, + ]); + + return $response->withJson([], 200); + } + + /** + * Restores a deleted organisation + * + * Before doing so, checks that: + * 1. The user has permission to restore this organisation; + * 2. The submitted data is valid. + * This route requires authentication (and should generally be limited to admins or the root user). + * + * Request type: PUT + * + * @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 restore(Request $request, Response $response, $args) + { + $organisation = $this->getOrganisationFromParams($args, true); + + // 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, 'restore_organisation', [ + 'organisation' => $organisation, + ])) { + throw new ForbiddenException(); + } + + /** @var \UserFrosting\Support\Repository\Repository $config */ + $config = $this->ci->config; + + /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ + $classMapper = $this->ci->classMapper; + + // Begin transaction - DB will be rolled back if an exception occurs + Capsule::transaction(function () use ($organisation, $currentUser) { + $organisation->restore(); + + // Create activity record + $this->ci->userActivityLogger->info("User {$currentUser->user_name} restored deleted organisation {$organisation->name}.", [ + 'type' => 'organisation_restore', + 'user_id' => $currentUser->id, + ]); + }); + + /** @var \UserFrosting\Sprinkle\Core\Alert\AlertStream $ms */ + $ms = $this->ci->alerts; + + $ms->addMessageTranslated('success', 'ORGANISATION.RESTORE_SUCCESSFUL', [ + 'name' => $organisation->name, + ]); + + return $response->withJson([], 200); + } + + /** * Returns a list of Organisations. * @@ -514,6 +653,49 @@ class OrganisationController extends SimpleController return $sprunje->toResponse($response); } + /** + * Returns a list of Organisations. + * + * Generates a list of organisations, optionally paginated, sorted and/or filtered. + * 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 getListDeleted(Request $request, Response $response, $args) + { + // GET parameters + $params = $request->getQueryParams(); + + /** @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, 'uri_deleted_organisations')) { + throw new ForbiddenException(); + } + + /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ + $classMapper = $this->ci->classMapper; + + $sprunje = $classMapper->createInstance('organisation_sprunje', $classMapper, $params); + $sprunje->extendQuery(function ($query) use ($user) { + return $query->onlyTrashed(); + }); + + // Be careful how you consume this data - it has not been escaped and contains untrusted user-supplied content. + // For example, if you plan to insert it into an HTML DOM, you must escape it on the client side (or use client-side templating). + return $sprunje->toResponse($response); + } + /** * Get deletion confirmation modal. * @@ -561,6 +743,53 @@ class OrganisationController extends SimpleController ]); } + /** + * Get permenent deletion 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 getModalConfirmPermenentDelete(Request $request, Response $response, $args) + { + // GET parameters + $params = $request->getQueryParams(); + + $organisation = $this->getOrganisationFromParams($params, true); + + // 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, 'permenent_delete_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-permenent-delete-organisation.html.twig', [ + 'organisation' => $organisation, + 'form' => [ + 'action' => "api/organisations/o/{$organisation->slug}/permenent", + ], + ]); + } + /** * Renders the modal form for creating a new organisation. * @@ -872,6 +1101,37 @@ class OrganisationController extends SimpleController return $this->ci->view->render($response, 'pages/organisations.html.twig'); } + /** + * Renders the organisation listing page for deleted organisations. + * + * This page renders a table of organisations, with dropdown menus for admin actions for each organisation. + * Actions typically include: restore organisation, permenently delete etd. + * 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 pageListDeleted(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, 'uri_deleted_organisations')) { + throw new ForbiddenException(); + } + + return $this->ci->view->render($response, 'pages/deleted-organisations.html.twig'); + } + /** * Get organisation from params. @@ -882,7 +1142,7 @@ class OrganisationController extends SimpleController * * @return Organisation */ - protected function getOrganisationFromParams($params) + protected function getOrganisationFromParams($params, $withTrashed = false) { // Load the request schema $schema = new RequestSchema('schema://requests/organisation/get-by-slug.yaml'); @@ -908,9 +1168,14 @@ class OrganisationController extends SimpleController /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ $classMapper = $this->ci->classMapper; + $query = $classMapper->getClassMapping('organisation')::where('slug', $data['slug']); + + if ($withTrashed) { + $query->withTrashed(); + } + // Get the organisation - $organisation = $classMapper->getClassMapping('organisation')::where('slug', $data['slug']) - ->first(); + $organisation = $query->first(); return $organisation; } diff --git a/src/Database/Seeds/OrganisationPermissions.php b/src/Database/Seeds/OrganisationPermissions.php index a703aa3..9517895 100644 --- a/src/Database/Seeds/OrganisationPermissions.php +++ b/src/Database/Seeds/OrganisationPermissions.php @@ -99,6 +99,18 @@ class OrganisationPermissions extends BaseSeed 'conditions' => 'always()', 'description' => 'Delete an organisation.', ]), + 'restore_organisation' => new Permission([ + 'slug' => 'restore_organisation', + 'name' => 'Restore organisation', + 'conditions' => 'always()', + 'description' => 'Restore a deleted organisation.', + ]), + 'permenent_delete_organisation' => new Permission([ + 'slug' => 'permenent_delete_organisation', + 'name' => 'Permenently delete organisation', + 'conditions' => 'always()', + 'description' => 'Permenently delete an organisation.', + ]), 'uri_organisation' => new Permission([ 'slug' => 'uri_organisation', 'name' => 'View organisation', @@ -117,9 +129,16 @@ class OrganisationPermissions extends BaseSeed 'conditions' => 'always()', 'description' => 'View a page containing a list of organisations.', ]), + 'uri_deleted_organisations' => new Permission([ + 'slug' => 'uri_deleted_organisations', + 'name' => 'Deleted organisation management page', + 'conditions' => 'always()', + 'description' => 'View a page containing a list of deleted organisations.', + ]), ]; } + /** * Save permissions. * @@ -175,6 +194,9 @@ class OrganisationPermissions extends BaseSeed $permissions['delete_organisation']->id, $permissions['uri_organisations']->id, $permissions['uri_organisation']->id, + $permissions['uri_deleted_organisations']->id, + $permissions['restore_organisation']->id, + $permissions['permenent_delete_organisation']->id, ]); } diff --git a/templates/modals/confirm-permenent-delete-organisation.html.twig b/templates/modals/confirm-permenent-delete-organisation.html.twig new file mode 100644 index 0000000..22c8d6e --- /dev/null +++ b/templates/modals/confirm-permenent-delete-organisation.html.twig @@ -0,0 +1,17 @@ +{% extends "modals/modal.html.twig" %} + +{% block modal_title %}{{translate("ORGANISATION.PERMENENT_DELETE")}}{% endblock %} + +{% block modal_body %} +
+ {% include "forms/csrf.html.twig" %} +
+
+

{{translate("ORGANISATION.PERMENENT_DELETE_CONFIRM", {name: organisation.name})}}
{{translate("ACTION_CANNOT_UNDONE")}}

+
+
+ + +
+
+{% endblock %} diff --git a/templates/pages/deleted-organisations.html.twig b/templates/pages/deleted-organisations.html.twig new file mode 100644 index 0000000..b68a6ba --- /dev/null +++ b/templates/pages/deleted-organisations.html.twig @@ -0,0 +1,50 @@ +{% extends "pages/abstract/dashboard.html.twig" %} + +{% block stylesheets_page %} + + {{ assets.css('css/form-widgets') | raw }} +{% endblock %} + +{# Overrides blocks in head of base template #} +{% block page_title %}{{ translate("DELETED") }} {{ translate("ORGANISATION", 2) }}{% endblock %} + +{% block page_description %}{{ translate("ORGANISATION.DELETED_PAGE_DESCRIPTION") }}{% endblock %} + +{% block body_matter %} +
+
+
+
+

{{ translate("DELETED") }} {{translate('ORGANISATION', 2)}}

+ {% include "tables/table-tool-menu.html.twig" %} +
+
+ {% include "tables/deleted-organisations.html.twig" with { + "table" : { + "id" : "table-organisations" + } + } + %} +
+ +
+
+
+{% endblock %} +{% block scripts_page %} + + + + + {{ assets.js('js/form-widgets') | raw }} + + + {{ assets.js('js/pages/organisations') | raw }} + +{% endblock %} diff --git a/templates/pages/organisations.html.twig b/templates/pages/organisations.html.twig index 11222e3..1596e27 100644 --- a/templates/pages/organisations.html.twig +++ b/templates/pages/organisations.html.twig @@ -31,8 +31,10 @@ - {% endif %} - {% if checkAccess('register_organisation') %} + + {% elseif checkAccess('register_organisation') %} diff --git a/templates/tables/deleted-organisations.html.twig b/templates/tables/deleted-organisations.html.twig new file mode 100644 index 0000000..eca89c9 --- /dev/null +++ b/templates/tables/deleted-organisations.html.twig @@ -0,0 +1,104 @@ +{# This partial template renders a table of organisations, to be populated with rows via an AJAX request. + # This extends a generic template for paginated tables. + # + # Note that this template contains a "skeleton" table with an empty table body, and then a block of Handlebars templates which are used + # to render the table cells with the data from the AJAX request. +#} + +{% extends "tables/table-paginated.html.twig" %} + +{% block table %} + + + + + + + + + + + + + +
{{translate('ORGANISATION')}} {{translate("DESCRIPTION")}} {{translate("STATUS")}} {{translate("ORGANISATION.MEMBER_COUNT")}} {{translate("ORGANISATION.ADMIN_COUNT")}} {{translate("ACTIONS")}}
+{% endblock %} + +{% block table_cell_templates %} + {# This contains a series of + + + + + + + + + + + {% endverbatim %} +{% endblock %}