From 1e9783fdb319917f8ce94894dccdc62c4017e84e Mon Sep 17 00:00:00 2001 From: Sonia Zorba <sonia.zorba@inaf.it> Date: Mon, 21 Sep 2020 16:34:57 +0200 Subject: [PATCH] Added panel showing the pending invited registration requests; Added lock attribute on groups to avoid removal of special groups; Other improvements --- gms-ui/package.json | 4 +- gms-ui/src/api/server/index.js | 16 ++- .../src/components/GenericSearchResults.vue | 27 ++--- gms-ui/src/components/GroupsPanel.vue | 2 +- .../components/InvitedRegistrationPanel.vue | 50 +++++++++ gms-ui/src/components/Main.vue | 3 + gms-ui/src/components/PermissionsPanel.vue | 2 +- gms-ui/src/components/UserSearchResult.vue | 2 +- .../src/components/modals/AddMemberModal.vue | 2 +- .../components/modals/AddPermissionModal.vue | 2 +- .../modals/ConfirmDeleteInvitedModal.vue | 37 +++++++ gms-ui/src/components/modals/SearchUser.vue | 2 +- gms-ui/src/store.js | 17 +++- .../controller/GroupsTabResponseBuilder.java | 6 ++ .../InvitedRegistrationController.java | 8 ++ .../manager/InvitedRegistrationManager.java | 47 +++++++++ .../java/it/inaf/ia2/gms/model/GroupNode.java | 9 ++ .../gms/model/response/GroupsTabResponse.java | 9 ++ .../response/InvitedRegistrationItem.java | 50 +++++++++ .../inaf/ia2/gms/persistence/GroupsDAO.java | 23 +++-- .../persistence/InvitedRegistrationDAO.java | 70 ++++++++++++- .../gms/persistence/model/GroupEntity.java | 9 ++ .../model/InvitedRegistration.java | 14 ++- .../inaf/ia2/gms/service/GroupsService.java | 6 +- .../ia2/gms/service/GroupsTreeBuilder.java | 1 + gms/src/main/resources/sql/init.sql | 1 + .../resources/static/help/help-admin.html | 8 ++ .../static/help/img/gms-invited-panel.jpg | Bin 0 -> 28890 bytes .../GroupsTabResponseBuilderTest.java | 6 +- .../InvitedRegistrationControllerTest.java | 44 ++++++++ .../InvitedRegistrationDAOTest.java | 95 ++++++++++++++++++ 31 files changed, 537 insertions(+), 35 deletions(-) create mode 100644 gms-ui/src/components/InvitedRegistrationPanel.vue create mode 100644 gms-ui/src/components/modals/ConfirmDeleteInvitedModal.vue create mode 100644 gms/src/main/java/it/inaf/ia2/gms/model/response/InvitedRegistrationItem.java create mode 100644 gms/src/main/resources/static/help/img/gms-invited-panel.jpg create mode 100644 gms/src/test/java/it/inaf/ia2/gms/controller/InvitedRegistrationControllerTest.java create mode 100644 gms/src/test/java/it/inaf/ia2/gms/persistence/InvitedRegistrationDAOTest.java diff --git a/gms-ui/package.json b/gms-ui/package.json index 287b62f..65869b3 100644 --- a/gms-ui/package.json +++ b/gms-ui/package.json @@ -45,7 +45,9 @@ "plugin:vue/essential", "eslint:recommended" ], - "rules": {}, + "rules": { + "no-console": "warn" + }, "parserOptions": { "parser": "babel-eslint" } diff --git a/gms-ui/src/api/server/index.js b/gms-ui/src/api/server/index.js index f7079c8..cfb352e 100644 --- a/gms-ui/src/api/server/index.js +++ b/gms-ui/src/api/server/index.js @@ -17,7 +17,7 @@ function apiRequest(options, showLoading = true, handleValidationErrors = false) loading(false); }) .catch(error => { - if(handleValidationErrors && error.response && error.response.status === 400) { + if (handleValidationErrors && error.response && error.response.status === 400) { reject(error.response.data); } else { dispatchApiErrorEvent(error); @@ -363,5 +363,19 @@ export default { 'Cache-Control': 'no-cache' } }, false); + }, + deleteInvitedRegistration(requestId, groupId) { + let url = BASE_API_URL + 'registration?' + + 'request_id=' + requestId + '&group_id=' + groupId; + return apiRequest({ + method: 'DELETE', + url: url, + withCredentials: true, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Cache-Control': 'no-cache' + } + }); } }; diff --git a/gms-ui/src/components/GenericSearchResults.vue b/gms-ui/src/components/GenericSearchResults.vue index e0277a7..ea163ca 100644 --- a/gms-ui/src/components/GenericSearchResults.vue +++ b/gms-ui/src/components/GenericSearchResults.vue @@ -1,18 +1,21 @@ <template> <div class="mt-sm-3"> <div> - <p>Search results:</p> - <b-list-group v-for="item in model.genericSearchResults.items" v-bind:key="item.id"> - <b-list-group-item href="#" v-on:click="openSearchResult(item)"> - <span class="float-left"> - <font-awesome-icon icon="folder" v-if="item.type === 'GROUP'"></font-awesome-icon> - <font-awesome-icon icon="user" v-if="item.type === 'USER'"></font-awesome-icon> - {{item.label}} - </span> - </b-list-group-item> - </b-list-group> - <Paginator :paginatedPanel="model.genericSearchResults" :onUpdate="updateSearchResults" :paginatorInput="input.genericSearch" /> + <div v-if="model.genericSearchResults.items && model.genericSearchResults.items.length > 0"> + <p>Search results:</p> + <b-list-group v-for="item in model.genericSearchResults.items" v-bind:key="item.id"> + <b-list-group-item href="#" v-on:click="openSearchResult(item)"> + <span class="float-left"> + <font-awesome-icon icon="folder" v-if="item.type === 'GROUP'"></font-awesome-icon> + <font-awesome-icon icon="user" v-if="item.type === 'USER'"></font-awesome-icon> + {{item.label}} + </span> + </b-list-group-item> + </b-list-group> + <Paginator :paginatedPanel="model.genericSearchResults" :onUpdate="updateSearchResults" :paginatorInput="input.genericSearch" /> + </div> </div> + <p v-if="model.genericSearchResults.items && model.genericSearchResults.items.length === 0">No entries were found matching your search</p> </div> </template> @@ -29,7 +32,7 @@ export default { model: state => state.model, input: state => state.input }), - created () { + created() { this.updateSearchResults(); }, watch: { diff --git a/gms-ui/src/components/GroupsPanel.vue b/gms-ui/src/components/GroupsPanel.vue index 748119a..b77bcb1 100644 --- a/gms-ui/src/components/GroupsPanel.vue +++ b/gms-ui/src/components/GroupsPanel.vue @@ -14,7 +14,7 @@ <font-awesome-icon icon="edit"></font-awesome-icon> </a> - <a href="#" v-on:click.stop.prevent="openRemoveGroupModal(group)" class="text-danger" title="Delete"> + <a href="#" v-on:click.stop.prevent="openRemoveGroupModal(group)" class="text-danger" title="Delete" v-if="!group.locked"> <font-awesome-icon icon="trash"></font-awesome-icon> </a> </span> diff --git a/gms-ui/src/components/InvitedRegistrationPanel.vue b/gms-ui/src/components/InvitedRegistrationPanel.vue new file mode 100644 index 0000000..a087bc6 --- /dev/null +++ b/gms-ui/src/components/InvitedRegistrationPanel.vue @@ -0,0 +1,50 @@ +<template> +<b-tab :title="'Invited (' + registrations.length + ')'" :title-link-class="{ 'd-none': registrations.length === 0 }"> + <table class="table b-table table-striped table-hover" v-if="registrations"> + <thead> + <tr> + <th>Email</th> + <th>Permission</th> + <th>Submitted at</th> + <th></th> + </tr> + </thead> + <tbody> + <tr v-for="(reg, index) in registrations" v-bind:key="index"> + <td>{{reg.email}}</td> + <td>{{reg.permission}}</td> + <td>{{reg.creationTime}}</td> + <td> + <a href="#" v-on:click.stop="openConfirmDeleteInvitedModal(reg)" class="text-danger" title="Remove permission"> + <font-awesome-icon icon="trash"></font-awesome-icon> + </a> + </td> + </tr> + </tbody> + </table> + <ConfirmDeleteInvitedModal ref="confirmDeleteInvitedModal" /> +</b-tab> +</template> + +<script> +import ConfirmDeleteInvitedModal from './modals/ConfirmDeleteInvitedModal.vue'; + +export default { + name: 'InvitedRegistrationPanel', + components: { + ConfirmDeleteInvitedModal + }, + computed: { + registrations() { + return this.$store.state.model.invitedRegistrations === null ? [] : this.$store.state.model.invitedRegistrations; + } + }, + methods: { + openConfirmDeleteInvitedModal(reg) { + let breadcrumbs = this.$store.state.model.breadcrumbs; + let currentGroupId = breadcrumbs[breadcrumbs.length - 1].groupId; + this.$refs.confirmDeleteInvitedModal.openConfirmDeleteInvitedModal(reg, currentGroupId); + } + } +} +</script> diff --git a/gms-ui/src/components/Main.vue b/gms-ui/src/components/Main.vue index 7c7829f..189dcac 100644 --- a/gms-ui/src/components/Main.vue +++ b/gms-ui/src/components/Main.vue @@ -6,6 +6,7 @@ <GroupsPanel /> <MembersPanel /> <PermissionsPanel /> + <InvitedRegistrationPanel /> <template slot="tabs-end"> <b-button variant="primary" class="in-tabs-header-btn" v-if="showAddGroupBtn" v-on:click="openAddGroupModal">Add group</b-button> <b-button variant="primary" class="in-tabs-header-btn" v-if="showAddMemberBtn" v-on:click="openAddMemberModal">Add member</b-button> @@ -25,6 +26,7 @@ import GroupsBreadcrumb from './GroupsBreadcrumb.vue' import GroupsPanel from './GroupsPanel.vue' import MembersPanel from './MembersPanel.vue' import PermissionsPanel from './PermissionsPanel.vue' +import InvitedRegistrationPanel from './InvitedRegistrationPanel.vue' import AddGroupModal from './modals/AddGroupModal.vue' import AddMemberModal from './modals/AddMemberModal.vue' import AddPermissionModal from './modals/AddPermissionModal.vue' @@ -37,6 +39,7 @@ export default { GroupsPanel, MembersPanel, PermissionsPanel, + InvitedRegistrationPanel, AddGroupModal, AddMemberModal, AddPermissionModal diff --git a/gms-ui/src/components/PermissionsPanel.vue b/gms-ui/src/components/PermissionsPanel.vue index c6631b8..88f2aaa 100644 --- a/gms-ui/src/components/PermissionsPanel.vue +++ b/gms-ui/src/components/PermissionsPanel.vue @@ -1,7 +1,7 @@ <template> <b-tab title="Permissions" :title-link-class="{ 'd-none': (model.permission !== 'ADMIN') }"> <div v-if="model.permissionsPanel !== null"> - <table class="table b-table table-striped table-hover"> + <table class="table b-table table-striped table-hover" v-if="model.permissionsPanel.items.length > 0"> <thead> <tr> <th>User</th> diff --git a/gms-ui/src/components/UserSearchResult.vue b/gms-ui/src/components/UserSearchResult.vue index d5ccf14..0f95b90 100644 --- a/gms-ui/src/components/UserSearchResult.vue +++ b/gms-ui/src/components/UserSearchResult.vue @@ -29,7 +29,7 @@ <b-row> <b-col lg="10"> <b-list-group> - <b-list-group-item v-for="(identity, index) in user.identities" v-bind:key="index"> + <b-list-group-item :class="{ 'list-group-item-info': identity.primary }" v-for="(identity, index) in user.identities" v-bind:key="index"> <dl class="mb-0 ml-0 row"> <dt class="col-3">Type</dt><dd class="col-9">{{identity.type}}</dd> <dt class="col-3">Email</dt><dd class="col-9">{{identity.email}}</dd> diff --git a/gms-ui/src/components/modals/AddMemberModal.vue b/gms-ui/src/components/modals/AddMemberModal.vue index 13e91d9..11634bd 100644 --- a/gms-ui/src/components/modals/AddMemberModal.vue +++ b/gms-ui/src/components/modals/AddMemberModal.vue @@ -1,5 +1,5 @@ <template> -<b-modal id="add-member-modal" title="Add member" ok-title="Add" @shown="afterShow" @ok="addMember"> +<b-modal id="add-member-modal" title="Add member" ok-title="Add" @shown="afterShow" @ok="addMember" size="lg"> <SearchUser ref="searchUser" @searchUserEnter="addMember" /> </b-modal> </template> diff --git a/gms-ui/src/components/modals/AddPermissionModal.vue b/gms-ui/src/components/modals/AddPermissionModal.vue index b210f47..e988ee3 100644 --- a/gms-ui/src/components/modals/AddPermissionModal.vue +++ b/gms-ui/src/components/modals/AddPermissionModal.vue @@ -1,5 +1,5 @@ <template> -<b-modal id="add-permission-modal" title="Add permission" @show="beforeShow" @shown="afterShow" :ok-title="okTitle" @ok="addPermission" :ok-variant="okBtnVariant"> +<b-modal id="add-permission-modal" title="Add permission" @show="beforeShow" @shown="afterShow" :ok-title="okTitle" @ok="addPermission" :ok-variant="okBtnVariant" size="lg"> <SearchUser ref="searchUser" @searchUserEnter="addPermission" /> <b-alert :show="!!existingPermission" variant="warning" class="mt-3"> <strong>Warning</strong>: the user has already a permission ({{existingPermission}}). Click confirm to override it. diff --git a/gms-ui/src/components/modals/ConfirmDeleteInvitedModal.vue b/gms-ui/src/components/modals/ConfirmDeleteInvitedModal.vue new file mode 100644 index 0000000..e248834 --- /dev/null +++ b/gms-ui/src/components/modals/ConfirmDeleteInvitedModal.vue @@ -0,0 +1,37 @@ +<template> +<b-modal id="confirm-delete-invited-modal" title="Confirm action" ok-title="Delete" @ok="deleteInvitedRegistration" ok-variant="danger"> + <p v-if="invitedRegistration">Are you sure that you want to remove the invited registration for {{invitedRegistration.email}}?</p> +</b-modal> +</template> + +<script> +import client from 'api-client'; + +export default { + name: 'ConfirmDeleteInvitedModal', + data: function() { + return { + invitedRegistration: null, + groupId: null + } + }, + methods: { + openConfirmDeleteInvitedModal(invitedRegistration, groupId) { + this.invitedRegistration = invitedRegistration; + this.groupId = groupId; + this.$bvModal.show('confirm-delete-invited-modal'); + }, + deleteInvitedRegistration() { + let regId = this.invitedRegistration.id; + client.deleteInvitedRegistration(regId, this.groupId) + .then(() => { + let model = this.$store.state.model; + this.$store.commit('removeInvitedRegistration', regId); + if (model.invitedRegistrations.length === 0) { + this.$store.commit('setTabIndex', model.leaf ? 1 : 0); + } + }); + } + } +} +</script> diff --git a/gms-ui/src/components/modals/SearchUser.vue b/gms-ui/src/components/modals/SearchUser.vue index a6b0581..5f96f4b 100644 --- a/gms-ui/src/components/modals/SearchUser.vue +++ b/gms-ui/src/components/modals/SearchUser.vue @@ -50,7 +50,7 @@ export default { let user = res[i]; this.users.push({ value: user.id, - text: user.displayName + text: user.displayName + ' [' + user.id + ']' }); } if (this.users.length > 0) { diff --git a/gms-ui/src/store.js b/gms-ui/src/store.js index fe02ab5..a0e8e38 100644 --- a/gms-ui/src/store.js +++ b/gms-ui/src/store.js @@ -15,6 +15,7 @@ export default new Vuex.Store({ groupsPanel: null, permissionsPanel: null, membersPanel: null, + invitedRegistrations: null, permission: null, leaf: false, user: null, @@ -45,12 +46,14 @@ export default new Vuex.Store({ state.model.breadcrumbs = model.breadcrumbs; state.model.groupsPanel = model.groupsPanel; state.model.permission = model.permission; + state.model.invitedRegistrations = model.invitedRegistrations; state.model.user = model.user; }, updateGroups(state, model) { state.model.breadcrumbs = model.breadcrumbs; state.model.groupsPanel = model.groupsPanel; state.model.permission = model.permission; + state.model.invitedRegistrations = model.invitedRegistrations; state.model.leaf = model.leaf; }, updateGroupsPanel(state, groupsPanel) { @@ -59,7 +62,7 @@ export default new Vuex.Store({ }, updatePermissionsPanel(state, permissionsPanel) { state.model.permissionsPanel = permissionsPanel; - for(let up of permissionsPanel.items) { + for (let up of permissionsPanel.items) { Vue.set(up, 'editable', false); } state.input.paginatorPage = permissionsPanel.currentPage; @@ -94,6 +97,18 @@ export default new Vuex.Store({ }, setGenericSearchFilter(state, filter) { state.input.genericSearch.filter = filter; + }, + removeInvitedRegistration(state, regId) { + let index = -1; + for (var i = 0; i < state.model.invitedRegistrations.length; i++) { + if (state.model.invitedRegistrations[i].id === regId) { + index = i; + break; + } + } + if (index !== -1) { + state.model.invitedRegistrations.splice(index, 1); + } } }, actions: { diff --git a/gms/src/main/java/it/inaf/ia2/gms/controller/GroupsTabResponseBuilder.java b/gms/src/main/java/it/inaf/ia2/gms/controller/GroupsTabResponseBuilder.java index f560d5d..2718b19 100644 --- a/gms/src/main/java/it/inaf/ia2/gms/controller/GroupsTabResponseBuilder.java +++ b/gms/src/main/java/it/inaf/ia2/gms/controller/GroupsTabResponseBuilder.java @@ -2,6 +2,7 @@ package it.inaf.ia2.gms.controller; import it.inaf.ia2.gms.authn.SessionData; import it.inaf.ia2.gms.manager.GroupsManager; +import it.inaf.ia2.gms.manager.InvitedRegistrationManager; import it.inaf.ia2.gms.manager.PermissionsManager; import it.inaf.ia2.gms.model.Permission; import it.inaf.ia2.gms.model.request.GroupsRequest; @@ -30,6 +31,9 @@ public class GroupsTabResponseBuilder { @Autowired private GroupsTreeBuilder groupsListBuilder; + @Autowired + private InvitedRegistrationManager invitedRegistrationManager; + public GroupsTabResponse getGroupsTab(GroupsRequest request) { GroupEntity group = groupsService.getGroupById(request.getGroupId()); @@ -46,6 +50,8 @@ public class GroupsTabResponseBuilder { response.setLeaf(group.isLeaf()); + response.setInvitedRegistrations(invitedRegistrationManager.getInvitedRegistrationsForGroup(group)); + return response; } } diff --git a/gms/src/main/java/it/inaf/ia2/gms/controller/InvitedRegistrationController.java b/gms/src/main/java/it/inaf/ia2/gms/controller/InvitedRegistrationController.java index 0a4554c..82dd95a 100644 --- a/gms/src/main/java/it/inaf/ia2/gms/controller/InvitedRegistrationController.java +++ b/gms/src/main/java/it/inaf/ia2/gms/controller/InvitedRegistrationController.java @@ -10,7 +10,9 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; @@ -55,6 +57,12 @@ public class InvitedRegistrationController { } } + @DeleteMapping(value = "/registration", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity<?> deleteInvitedRegistration(@RequestParam("request_id") String requestId, @RequestParam("group_id") String groupId) { + invitedRegistrationManager.deleteInvitedRegistration(requestId, groupId); + return ResponseEntity.noContent().build(); + } + private String getFileContent(String templateFileName) throws IOException { try (InputStream in = InvitedRegistrationController.class.getClassLoader().getResourceAsStream("templates/" + templateFileName)) { Scanner s = new Scanner(in).useDelimiter("\\A"); diff --git a/gms/src/main/java/it/inaf/ia2/gms/manager/InvitedRegistrationManager.java b/gms/src/main/java/it/inaf/ia2/gms/manager/InvitedRegistrationManager.java index 73bdea1..f30dca3 100644 --- a/gms/src/main/java/it/inaf/ia2/gms/manager/InvitedRegistrationManager.java +++ b/gms/src/main/java/it/inaf/ia2/gms/manager/InvitedRegistrationManager.java @@ -1,8 +1,10 @@ package it.inaf.ia2.gms.manager; +import it.inaf.ia2.gms.exception.BadRequestException; import it.inaf.ia2.gms.exception.NotFoundException; import it.inaf.ia2.gms.exception.UnauthorizedException; import it.inaf.ia2.gms.model.Permission; +import it.inaf.ia2.gms.model.response.InvitedRegistrationItem; import it.inaf.ia2.gms.persistence.GroupsDAO; import it.inaf.ia2.gms.persistence.InvitedRegistrationDAO; import it.inaf.ia2.gms.persistence.LoggingDAO; @@ -14,8 +16,10 @@ import it.inaf.ia2.gms.service.PermissionsService; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; import java.util.Base64; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.UUID; @@ -119,4 +123,47 @@ public class InvitedRegistrationManager extends UserAwareComponent { return Optional.ofNullable(invitedRegistration); } + + public List<InvitedRegistrationItem> getInvitedRegistrationsForGroup(GroupEntity group) { + + if (permissionsManager.getCurrentUserPermission(group) != Permission.ADMIN) { + return null; + } + + List<InvitedRegistrationItem> items = new ArrayList<>(); + + for (InvitedRegistration reg : invitedRegistrationDAO.getPendingInvitedRegistrationsForGroup(group.getId())) { + + Map<String, Permission> map = reg.getGroupsPermissions(); + + if (map != null) { + for (Permission permission : map.values()) { + InvitedRegistrationItem item = new InvitedRegistrationItem() + .setId(reg.getId()) + .setEmail(reg.getEmail()) + .setPermission(permission) + .setCreationTime(reg.getCreationTime()); + items.add(item); + } + } + } + + return items; + } + + public void deleteInvitedRegistration(String registrationId, String groupId) { + + GroupEntity group = groupsDAO.findGroupById(groupId) + .orElseThrow(() -> new BadRequestException("No group found for given id: " + groupId)); + + if (permissionsManager.getUserPermission(group, getCurrentUserId()) != Permission.ADMIN) { + throw new UnauthorizedException("Only administrators can delete invited registrations!"); + } + + invitedRegistrationDAO.deleteInvitedRegistrationRequest(registrationId, groupId); + + loggingDAO.logAction("Deleted invited registration request. " + + "[request_id=" + registrationId + ", group_id=" + groupId + + ", group_name=" + group.getName() + "]"); + } } diff --git a/gms/src/main/java/it/inaf/ia2/gms/model/GroupNode.java b/gms/src/main/java/it/inaf/ia2/gms/model/GroupNode.java index 381d846..b7237fc 100644 --- a/gms/src/main/java/it/inaf/ia2/gms/model/GroupNode.java +++ b/gms/src/main/java/it/inaf/ia2/gms/model/GroupNode.java @@ -7,6 +7,7 @@ public class GroupNode { private Permission permission; private boolean hasChildren; private boolean leaf; + private boolean locked; public String getGroupId() { return groupId; @@ -47,4 +48,12 @@ public class GroupNode { public void setLeaf(boolean leaf) { this.leaf = leaf; } + + public boolean isLocked() { + return locked; + } + + public void setLocked(boolean locked) { + this.locked = locked; + } } diff --git a/gms/src/main/java/it/inaf/ia2/gms/model/response/GroupsTabResponse.java b/gms/src/main/java/it/inaf/ia2/gms/model/response/GroupsTabResponse.java index 0eb8665..d3d75da 100644 --- a/gms/src/main/java/it/inaf/ia2/gms/model/response/GroupsTabResponse.java +++ b/gms/src/main/java/it/inaf/ia2/gms/model/response/GroupsTabResponse.java @@ -11,6 +11,7 @@ public class GroupsTabResponse { private PaginatedData<GroupNode> groupsPanel; // current group permissions private Permission permission; + private List<InvitedRegistrationItem> invitedRegistrations; private boolean leaf; @@ -45,4 +46,12 @@ public class GroupsTabResponse { public void setLeaf(boolean leaf) { this.leaf = leaf; } + + public List<InvitedRegistrationItem> getInvitedRegistrations() { + return invitedRegistrations; + } + + public void setInvitedRegistrations(List<InvitedRegistrationItem> invitedRegistrations) { + this.invitedRegistrations = invitedRegistrations; + } } diff --git a/gms/src/main/java/it/inaf/ia2/gms/model/response/InvitedRegistrationItem.java b/gms/src/main/java/it/inaf/ia2/gms/model/response/InvitedRegistrationItem.java new file mode 100644 index 0000000..cc31f77 --- /dev/null +++ b/gms/src/main/java/it/inaf/ia2/gms/model/response/InvitedRegistrationItem.java @@ -0,0 +1,50 @@ +package it.inaf.ia2.gms.model.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import it.inaf.ia2.gms.model.Permission; +import java.util.Date; + +public class InvitedRegistrationItem { + + private String id; + private String email; + private Permission permission; + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") + private Date creationTime; + + public String getId() { + return id; + } + + public InvitedRegistrationItem setId(String id) { + this.id = id; + return this; + } + + public String getEmail() { + return email; + } + + public InvitedRegistrationItem setEmail(String email) { + this.email = email; + return this; + } + + public Permission getPermission() { + return permission; + } + + public InvitedRegistrationItem setPermission(Permission permission) { + this.permission = permission; + return this; + } + + public Date getCreationTime() { + return creationTime; + } + + public InvitedRegistrationItem setCreationTime(Date creationTime) { + this.creationTime = creationTime; + return this; + } +} diff --git a/gms/src/main/java/it/inaf/ia2/gms/persistence/GroupsDAO.java b/gms/src/main/java/it/inaf/ia2/gms/persistence/GroupsDAO.java index 4228593..3cbffa5 100644 --- a/gms/src/main/java/it/inaf/ia2/gms/persistence/GroupsDAO.java +++ b/gms/src/main/java/it/inaf/ia2/gms/persistence/GroupsDAO.java @@ -84,7 +84,7 @@ public class GroupsDAO { public Optional<GroupEntity> findGroupById(String groupId) { - String sql = "SELECT id, name, path, is_leaf from gms_group WHERE id = ?"; + String sql = "SELECT id, name, path, is_leaf, locked from gms_group WHERE id = ?"; return jdbcTemplate.query(conn -> { PreparedStatement ps = conn.prepareStatement(sql); @@ -97,6 +97,7 @@ public class GroupsDAO { group.setName(resultSet.getString("name")); group.setPath(resultSet.getString("path")); group.setLeaf(resultSet.getBoolean("is_leaf")); + group.setLocked(resultSet.getBoolean("locked")); return Optional.of(group); } return Optional.empty(); @@ -105,7 +106,7 @@ public class GroupsDAO { public Optional<GroupEntity> findGroupByPath(String path) { - String sql = "SELECT id, name, is_leaf from gms_group WHERE path = ?"; + String sql = "SELECT id, name, is_leaf, locked from gms_group WHERE path = ?"; return jdbcTemplate.query(conn -> { PreparedStatement ps = conn.prepareStatement(sql); @@ -117,6 +118,7 @@ public class GroupsDAO { group.setId(resultSet.getString("id")); group.setName(resultSet.getString("name")); group.setLeaf(resultSet.getBoolean("is_leaf")); + group.setLocked(resultSet.getBoolean("locked")); group.setPath(path); return Optional.of(group); } @@ -132,7 +134,7 @@ public class GroupsDAO { return jdbcTemplate.query(conn -> { - String sql = "SELECT id, name, path, is_leaf from gms_group WHERE id IN ("; + String sql = "SELECT id, name, path, is_leaf, locked from gms_group WHERE id IN ("; sql += String.join(",", identifiers.stream().map(p -> "?").collect(Collectors.toList())); sql += ")"; @@ -150,6 +152,7 @@ public class GroupsDAO { group.setName(resultSet.getString("name")); group.setPath(resultSet.getString("path")); group.setLeaf(resultSet.getBoolean("is_leaf")); + group.setLocked(resultSet.getBoolean("locked")); groups.add(group); } return groups; @@ -158,7 +161,7 @@ public class GroupsDAO { public Optional<GroupEntity> findGroupByParentAndName(String parentPath, String childName) { - String sql = "SELECT id, path, is_leaf from gms_group WHERE name = ? AND path ~ ?"; + String sql = "SELECT id, path, is_leaf, locked from gms_group WHERE name = ? AND path ~ ?"; return jdbcTemplate.query(conn -> { PreparedStatement ps = conn.prepareStatement(sql); @@ -172,6 +175,7 @@ public class GroupsDAO { group.setName(childName); group.setPath(resultSet.getString("path")); group.setLeaf(resultSet.getBoolean("is_leaf")); + group.setLocked(resultSet.getBoolean("locked")); return Optional.of(group); } return Optional.empty(); @@ -191,9 +195,9 @@ public class GroupsDAO { String sql; if (hasSearchFilter) { - sql = "SELECT id, name, path, is_leaf FROM gms_group WHERE path ~ ? AND name ILIKE ? ORDER BY name"; + sql = "SELECT id, name, path, is_leaf, locked FROM gms_group WHERE path ~ ? AND name ILIKE ? ORDER BY name"; } else { - sql = "SELECT id, name, path, is_leaf FROM gms_group WHERE path ~ ? ORDER BY name"; + sql = "SELECT id, name, path, is_leaf, locked FROM gms_group WHERE path ~ ? ORDER BY name"; } return jdbcTemplate.query(conn -> { @@ -218,7 +222,7 @@ public class GroupsDAO { public List<GroupEntity> getAllChildren(String path) { - String sql = "SELECT id, name, path, is_leaf FROM gms_group WHERE path <@ ? AND path <> ? ORDER BY nlevel(path) DESC"; + String sql = "SELECT id, name, path, is_leaf, locked FROM gms_group WHERE path <@ ? AND path <> ? ORDER BY nlevel(path) DESC"; return jdbcTemplate.query(conn -> { PreparedStatement ps = conn.prepareStatement(sql); @@ -232,7 +236,7 @@ public class GroupsDAO { public List<GroupEntity> findGroupsByNames(List<String> names) { - String sql = "SELECT id, name, path, is_leaf from gms_group WHERE name IN (" + String sql = "SELECT id, name, path, is_leaf, locked from gms_group WHERE name IN (" + String.join(",", names.stream().map(g -> "?").collect(Collectors.toList())) + ")"; @@ -256,6 +260,7 @@ public class GroupsDAO { group.setName(resultSet.getString("name")); group.setPath(resultSet.getString("path")); group.setLeaf(resultSet.getBoolean("is_leaf")); + group.setLocked(resultSet.getBoolean("locked")); groups.add(group); } return groups; @@ -326,7 +331,7 @@ public class GroupsDAO { public List<GroupEntity> searchGroups(String searchText) { - String sql = "SELECT id, name, path, is_leaf from gms_group WHERE name ILIKE ?"; + String sql = "SELECT id, name, path, is_leaf, locked from gms_group WHERE name ILIKE ?"; return jdbcTemplate.query(conn -> { PreparedStatement ps = conn.prepareStatement(sql); diff --git a/gms/src/main/java/it/inaf/ia2/gms/persistence/InvitedRegistrationDAO.java b/gms/src/main/java/it/inaf/ia2/gms/persistence/InvitedRegistrationDAO.java index af61db9..43415d6 100644 --- a/gms/src/main/java/it/inaf/ia2/gms/persistence/InvitedRegistrationDAO.java +++ b/gms/src/main/java/it/inaf/ia2/gms/persistence/InvitedRegistrationDAO.java @@ -4,6 +4,9 @@ import it.inaf.ia2.gms.model.Permission; import it.inaf.ia2.gms.persistence.model.InvitedRegistration; import java.sql.PreparedStatement; import java.sql.Types; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -54,7 +57,7 @@ public class InvitedRegistrationDAO { public Optional<InvitedRegistration> getInvitedRegistrationFromToken(String tokenHash) { - String sqlReq = "SELECT id, email FROM invited_registration_request WHERE token_hash = ? AND done IS NOT true"; + String sqlReq = "SELECT id, email, creation_time FROM invited_registration_request WHERE token_hash = ? AND done IS NOT true"; InvitedRegistration registration = jdbcTemplate.query(conn -> { PreparedStatement ps = conn.prepareStatement(sqlReq); @@ -65,6 +68,7 @@ public class InvitedRegistrationDAO { InvitedRegistration reg = new InvitedRegistration(); reg.setId(resultSet.getString("id")); reg.setEmail(resultSet.getString("email")); + reg.setCreationTime(new Date(resultSet.getDate("creation_time").getTime())); return reg; } return null; @@ -126,7 +130,7 @@ public class InvitedRegistrationDAO { return; } - String sql = "DELETE FROM invited_registration_request_group WHERE group_id = (" + String sql = "DELETE FROM invited_registration_request_group WHERE group_id IN (" + String.join(",", groupIds.stream().map(g -> "?").collect(Collectors.toList())) + ")"; @@ -143,4 +147,66 @@ public class InvitedRegistrationDAO { jdbcTemplate.update("DELETE FROM invited_registration_request WHERE id NOT IN " + "(SELECT request_id FROM invited_registration_request_group)"); } + + public List<InvitedRegistration> getPendingInvitedRegistrationsForGroup(String groupId) { + + String sql = "SELECT id, email, creation_time, permission\n" + + "FROM invited_registration_request r\n" + + "JOIN invited_registration_request_group rg ON r.id = rg.request_id\n" + + "WHERE done IS NOT TRUE AND rg.group_id = ?"; + + return jdbcTemplate.query(sql, + ps -> { + ps.setString(1, groupId); + }, + rs -> { + // key: id + Map<String, InvitedRegistration> map = new HashMap<>(); + + while (rs.next()) { + + String id = rs.getString("id"); + + InvitedRegistration reg = map.get(id); + if (reg == null) { + String email = rs.getString("email"); + Date creationTime = new Date(rs.getDate("creation_time").getTime()); + reg = new InvitedRegistration() + .setId(id) + .setEmail(email) + .setCreationTime(creationTime); + map.put(id, reg); + } + + if (reg.getGroupsPermissions() == null) { + reg.setGroupsPermissions(new HashMap<>()); + } + + Permission permission = Permission.valueOf(rs.getString("permission")); + + reg.getGroupsPermissions().put(groupId, permission); + } + + List<InvitedRegistration> registrations = new ArrayList<>(map.values()); + + Collections.sort(registrations, (reg1, reg2) -> reg1.getEmail().compareToIgnoreCase(reg2.getEmail())); + + return registrations; + }); + } + + public void deleteInvitedRegistrationRequest(String requestId, String groupId) { + + String sql = "DELETE FROM invited_registration_request_group\n" + + "WHERE request_id = ? AND group_id = ?"; + + jdbcTemplate.update(sql, ps -> { + ps.setString(1, requestId); + ps.setString(2, groupId); + }); + + // Cleanup orphan invited requests + jdbcTemplate.update("DELETE FROM invited_registration_request WHERE id NOT IN " + + "(SELECT request_id FROM invited_registration_request_group)"); + } } diff --git a/gms/src/main/java/it/inaf/ia2/gms/persistence/model/GroupEntity.java b/gms/src/main/java/it/inaf/ia2/gms/persistence/model/GroupEntity.java index 1dc3ed1..20590cf 100644 --- a/gms/src/main/java/it/inaf/ia2/gms/persistence/model/GroupEntity.java +++ b/gms/src/main/java/it/inaf/ia2/gms/persistence/model/GroupEntity.java @@ -8,6 +8,7 @@ public class GroupEntity { private String name; private String path; private boolean leaf; + private boolean locked; public String getId() { return id; @@ -41,6 +42,14 @@ public class GroupEntity { this.leaf = leaf; } + public boolean isLocked() { + return locked; + } + + public void setLocked(boolean locked) { + this.locked = locked; + } + public String getParentPath() { if (path.isEmpty()) { return null; diff --git a/gms/src/main/java/it/inaf/ia2/gms/persistence/model/InvitedRegistration.java b/gms/src/main/java/it/inaf/ia2/gms/persistence/model/InvitedRegistration.java index e7a922b..9acc15e 100644 --- a/gms/src/main/java/it/inaf/ia2/gms/persistence/model/InvitedRegistration.java +++ b/gms/src/main/java/it/inaf/ia2/gms/persistence/model/InvitedRegistration.java @@ -1,6 +1,7 @@ package it.inaf.ia2.gms.persistence.model; import it.inaf.ia2.gms.model.Permission; +import java.util.Date; import java.util.Map; public class InvitedRegistration { @@ -10,6 +11,7 @@ public class InvitedRegistration { private String email; private boolean done; private String userId; + private Date creationTime; private Map<String, Permission> groupsPermissions; public String getId() { @@ -52,8 +54,18 @@ public class InvitedRegistration { return userId; } - public void setUserId(String userId) { + public InvitedRegistration setUserId(String userId) { this.userId = userId; + return this; + } + + public Date getCreationTime() { + return creationTime; + } + + public InvitedRegistration setCreationTime(Date creationTime) { + this.creationTime = creationTime; + return this; } public Map<String, Permission> getGroupsPermissions() { diff --git a/gms/src/main/java/it/inaf/ia2/gms/service/GroupsService.java b/gms/src/main/java/it/inaf/ia2/gms/service/GroupsService.java index 14d503e..f38717f 100644 --- a/gms/src/main/java/it/inaf/ia2/gms/service/GroupsService.java +++ b/gms/src/main/java/it/inaf/ia2/gms/service/GroupsService.java @@ -99,6 +99,10 @@ public class GroupsService { throw new UnauthorizedException("It is not possible to remove the ROOT"); } + if (group.isLocked()) { + throw new UnauthorizedException("Group " + group.getId() + " is locked and can't be deleted"); + } + String parentPath = group.getParentPath(); GroupEntity parent = groupsDAO.findGroupByPath(parentPath) .orElseThrow(() -> new BadRequestException("No group found at path " + parentPath)); @@ -117,7 +121,7 @@ public class GroupsService { groupsDAO.deleteGroup(g); } - loggingDAO.logAction("Group deleted, group_id=" + group.getId()); + loggingDAO.logAction("Group deleted [group_id=" + group.getId() + ", group_name=" + group.getName() + "]"); return parent; } diff --git a/gms/src/main/java/it/inaf/ia2/gms/service/GroupsTreeBuilder.java b/gms/src/main/java/it/inaf/ia2/gms/service/GroupsTreeBuilder.java index 80c5384..e3c2145 100644 --- a/gms/src/main/java/it/inaf/ia2/gms/service/GroupsTreeBuilder.java +++ b/gms/src/main/java/it/inaf/ia2/gms/service/GroupsTreeBuilder.java @@ -60,6 +60,7 @@ public class GroupsTreeBuilder { node.setGroupId(group.getId()); node.setGroupName(group.getName()); node.setLeaf(group.isLeaf()); + node.setLocked(group.isLocked()); node.setPermission(permission); nodes.add(node); }); diff --git a/gms/src/main/resources/sql/init.sql b/gms/src/main/resources/sql/init.sql index 6f4693e..05aec53 100644 --- a/gms/src/main/resources/sql/init.sql +++ b/gms/src/main/resources/sql/init.sql @@ -5,6 +5,7 @@ CREATE TABLE gms_group ( name text NOT NULL, path ltree NOT NULL, is_leaf boolean, + locked boolean, primary key(id) ); diff --git a/gms/src/main/resources/static/help/help-admin.html b/gms/src/main/resources/static/help/help-admin.html index f452453..093446d 100644 --- a/gms/src/main/resources/static/help/help-admin.html +++ b/gms/src/main/resources/static/help/help-admin.html @@ -102,6 +102,14 @@ You can also use the edit icon (<img src="img/pencil-icon.jpg" alt="" />) in order to change a permission.</p> + <h2 class="mt-4">Pending invited registrations</h2> + + <p>If a registration invitation has been sent and the user has not yet registered an additional tab appears:</p> + + <p> + <img src="img/gms-invited-panel.jpg" alt="" /> + </p> + <h2 class="mt-4">Seeing information about users</h2> <p>You can click on the user names in the Members or Permissions tab in order to see a detailed page about a specific user.</p> diff --git a/gms/src/main/resources/static/help/img/gms-invited-panel.jpg b/gms/src/main/resources/static/help/img/gms-invited-panel.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3f2b7d1cbe2bbac8dd040dfe569ffa9f99748b3c GIT binary patch literal 28890 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 literal 0 HcmV?d00001 diff --git a/gms/src/test/java/it/inaf/ia2/gms/controller/GroupsTabResponseBuilderTest.java b/gms/src/test/java/it/inaf/ia2/gms/controller/GroupsTabResponseBuilderTest.java index c4ac899..937fbe7 100644 --- a/gms/src/test/java/it/inaf/ia2/gms/controller/GroupsTabResponseBuilderTest.java +++ b/gms/src/test/java/it/inaf/ia2/gms/controller/GroupsTabResponseBuilderTest.java @@ -2,6 +2,7 @@ package it.inaf.ia2.gms.controller; import it.inaf.ia2.gms.authn.SessionData; import it.inaf.ia2.gms.manager.GroupsManager; +import it.inaf.ia2.gms.manager.InvitedRegistrationManager; import it.inaf.ia2.gms.manager.PermissionsManager; import it.inaf.ia2.gms.model.GroupNode; import it.inaf.ia2.gms.model.Permission; @@ -31,7 +32,7 @@ public class GroupsTabResponseBuilderTest { @Mock private GroupsManager groupsManager; - + @Mock private GroupsService groupsService; @@ -41,6 +42,9 @@ public class GroupsTabResponseBuilderTest { @Mock private GroupsTreeBuilder groupsTreeBuilder; + @Mock + private InvitedRegistrationManager invitedRegistrationManager; + @InjectMocks private GroupsTabResponseBuilder groupsTabResponseBuilder; diff --git a/gms/src/test/java/it/inaf/ia2/gms/controller/InvitedRegistrationControllerTest.java b/gms/src/test/java/it/inaf/ia2/gms/controller/InvitedRegistrationControllerTest.java new file mode 100644 index 0000000..c6c1c2e --- /dev/null +++ b/gms/src/test/java/it/inaf/ia2/gms/controller/InvitedRegistrationControllerTest.java @@ -0,0 +1,44 @@ +package it.inaf.ia2.gms.controller; + +import it.inaf.ia2.gms.manager.InvitedRegistrationManager; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import static org.mockito.ArgumentMatchers.eq; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.test.web.servlet.MockMvc; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +@RunWith(MockitoJUnitRunner.class) +public class InvitedRegistrationControllerTest { + + @Mock + private InvitedRegistrationManager manager; + + @InjectMocks + private InvitedRegistrationController controller; + + private MockMvc mockMvc; + + @Before + public void init() { + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + } + + @Test + public void testDeleteInvitedRegistration() throws Exception { + + mockMvc.perform(delete("/registration?request_id=req1&group_id=group1")) + .andDo(print()) + .andExpect(status().isNoContent()); + + verify(manager, times(1)).deleteInvitedRegistration(eq("req1"), eq("group1")); + } +} diff --git a/gms/src/test/java/it/inaf/ia2/gms/persistence/InvitedRegistrationDAOTest.java b/gms/src/test/java/it/inaf/ia2/gms/persistence/InvitedRegistrationDAOTest.java new file mode 100644 index 0000000..555f27b --- /dev/null +++ b/gms/src/test/java/it/inaf/ia2/gms/persistence/InvitedRegistrationDAOTest.java @@ -0,0 +1,95 @@ +package it.inaf.ia2.gms.persistence; + +import it.inaf.ia2.gms.DataSourceConfig; +import it.inaf.ia2.gms.HooksConfig; +import it.inaf.ia2.gms.model.Permission; +import it.inaf.ia2.gms.persistence.model.GroupEntity; +import it.inaf.ia2.gms.persistence.model.InvitedRegistration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.sql.DataSource; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringRunner; + +@RunWith(SpringRunner.class) +@ContextConfiguration(classes = {DataSourceConfig.class, HooksConfig.class}) +public class InvitedRegistrationDAOTest { + + @Autowired + private DataSource dataSource; + + private GroupsDAO groupsDAO; + + private InvitedRegistrationDAO dao; + + @Before + public void setUp() { + groupsDAO = new GroupsDAO(dataSource); + dao = new InvitedRegistrationDAO(dataSource); + } + + @Test + public void test() { + + GroupEntity root = new GroupEntity(); + root.setId("ROOT"); + root.setName("ROOT"); + root.setPath(""); + groupsDAO.createGroup(root); + + GroupEntity group1 = new GroupEntity(); + group1.setId("group1"); + group1.setName("Group1"); + group1.setPath(group1.getId()); + groupsDAO.createGroup(group1); + + GroupEntity group2 = new GroupEntity(); + group2.setId("group2"); + group2.setName("Group2"); + group2.setPath(group2.getId()); + groupsDAO.createGroup(group2); + + Map<String, Permission> groupsPermissions = new HashMap<>(); + groupsPermissions.put("group1", Permission.VIEW_MEMBERS); + groupsPermissions.put("group2", Permission.MANAGE_MEMBERS); + + InvitedRegistration reg = new InvitedRegistration() + .setId("id1") + .setEmail("test@inaf.it") + .setTokenHash("token_hash") + .setGroupsPermissions(groupsPermissions); + + dao.addInvitedRegistration(reg); + + InvitedRegistration regFromToken = dao.getInvitedRegistrationFromToken("token_hash").get(); + assertEquals(reg.getId(), regFromToken.getId()); + assertEquals(reg.getEmail(), regFromToken.getEmail()); + assertNotNull(regFromToken.getCreationTime()); + + InvitedRegistration regFromGroup = dao.getPendingInvitedRegistrationsForGroup("group1").get(0); + assertEquals(reg.getId(), regFromGroup.getId()); + assertEquals(reg.getEmail(), regFromGroup.getEmail()); + assertNotNull(regFromGroup.getCreationTime()); + + dao.setRegistrationDone(regFromGroup); + + assertTrue(dao.getPendingInvitedRegistrationsForGroup("group1").isEmpty()); + + List<String> groupsIds = new ArrayList<>(); + groupsIds.add(group1.getId()); + groupsIds.add(group2.getId()); + dao.deleteAllGroupsInvitedRegistrations(groupsIds); + + groupsDAO.deleteGroup(group1); + groupsDAO.deleteGroup(group2); + } +} -- GitLab