Skip to content

Commit 8b23948

Browse files
committed
Fixes #545
1 parent ee64663 commit 8b23948

File tree

6 files changed

+93
-17
lines changed

6 files changed

+93
-17
lines changed

client/src/api/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,9 @@ export function reportError(error) {
117117
return postPutJson("/api/v1/users/error", error, "post");
118118
}
119119

120+
export function deleteUser(userId) {
121+
return fetchDelete(`/api/v1/users/${userId}`);
122+
}
120123
//Invitations
121124
export function invitationByHash(hash) {
122125
return fetchJson(`/api/v1/invitations/public?hash=${hash}`, {}, {}, false);

client/src/components/User.js

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,26 @@ import {dateFromEpoch} from "../utils/Date";
55
import {AUTHORITIES, highestAuthority} from "../utils/UserRole";
66
import I18n from "../locale/I18n";
77
import Logo from "./Logo";
8-
import {Card, CardType} from "@surfnet/sds";
8+
import {Button, ButtonType, Card, CardType} from "@surfnet/sds";
99
import {isEmpty} from "../utils/Utils";
1010
import {deriveRemoteApplicationAttributes, reduceApplicationFromUserRoles} from "../utils/Manage";
1111
import {ReactComponent as SearchIcon} from "@surfnet/sds/icons/functional-icons/search.svg";
1212
import {MoreLessText} from "./MoreLessText";
1313
import {RoleCard} from "./RoleCard";
1414
import DOMPurify from "dompurify";
15+
import ConfirmationDialog from "./ConfirmationDialog";
16+
import {deleteUser} from "../api";
17+
import {useNavigate} from "react-router-dom";
18+
import {useAppStore} from "../stores/AppStore";
1519

1620
export const User = ({user, other, config, currentUser}) => {
21+
const navigate = useNavigate();
22+
const {setFlash} = useAppStore(state => state);
1723
const searchRef = useRef();
1824
const [query, setQuery] = useState("");
1925
const [queryApplication, setQueryApplication] = useState("");
26+
const [confirmation, setConfirmation] = useState({});
27+
const [confirmationOpen, setConfirmationOpen] = useState(false);
2028

2129
if (user.institutionAdmin) {
2230
(user.applications || []).forEach(application => deriveRemoteApplicationAttributes(application, I18n.locale));
@@ -104,6 +112,25 @@ export const User = ({user, other, config, currentUser}) => {
104112
);
105113
}
106114

115+
const doDeleteUser = confirmation => {
116+
if (confirmation) {
117+
setConfirmation({
118+
cancel: () => setConfirmationOpen(false),
119+
action: () => doDeleteUser(false),
120+
warning: true,
121+
question: I18n.t("users.deleteConfirmation", {name: user.name}),
122+
});
123+
setConfirmationOpen(true);
124+
} else {
125+
deleteUser(user.id).then(() => {
126+
setConfirmationOpen(false);
127+
setConfirmation({});
128+
navigate("/home/users");
129+
setFlash(I18n.t("users.deleteFlash", {name: user.name}));
130+
})
131+
}
132+
}
133+
107134
user.highestAuthority = I18n.t(`access.${highestAuthority(user, false)}`);
108135
const attributes = [["name"], ["sub"], ["eduPersonPrincipalName"], ["schacHomeOrganization"], ["email"], ["highestAuthority"],
109136
["lastActivity", true], ["organizationGUID"]];
@@ -117,16 +144,25 @@ export const User = ({user, other, config, currentUser}) => {
117144

118145
return (
119146
<section className={"user"}>
147+
{confirmationOpen && <ConfirmationDialog isOpen={confirmationOpen}
148+
cancel={confirmation.cancel}
149+
confirm={confirmation.action}
150+
question={confirmation.question}/>}
120151
{attributes.map((attr, index) => attribute(index, attr[0], attr[1]))}
121-
152+
{(currentUser.superUser && other && currentUser.id !== user.id) &&
153+
<div className="span-row">
154+
<Button type={ButtonType.Delete}
155+
onClick={() => doDeleteUser(true)}/>
156+
</div>
157+
}
122158
<h3 className={"title span-row "}>{I18n.t("users.roles")}</h3>
123159
{(highestAuthority(user, false) === AUTHORITIES.GUEST && !other) &&
124160
<p className={"span-row"}
125161
dangerouslySetInnerHTML={{__html: DOMPurify.sanitize(I18n.t("users.guestRoleOnly", {welcomeUrl: config.welcomeUrl}))}}/>}
126162
{(!hasRoles && user.superUser) &&
127163
<p className={"span-row "}>{I18n.t("users.noRolesInfo")}</p>}
128164
{(!hasRoles && user.institutionAdmin) &&
129-
<p className={"span-row "}>{I18n.t("users.noRolesInstitutionAdmin")}</p>}
165+
<p className={"span-row "}>{I18n.t(`users.noRolesInstitutionAdmin${other ? "Other" : ""}`, {name: user.name})}</p>}
130166
{(hasRoles) &&
131167
<>
132168
<div className="roles-search span-row">

client/src/locale/en.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@
108108
applications: "Applications",
109109
noRolesInfo: "You have no roles (which means you must be super-user)",
110110
noRolesInstitutionAdmin: "You are an institution admin and you have no roles (but you might have access to applications)",
111+
noRolesInstitutionAdminOther: "{{name}} is an institution admin and has no roles (but might have access to applications)",
111112
noRolesNoApplicationsInstitutionAdmin: "You are an institution admin, but you have no roles and apparently your institution has also no access to applications",
112113
guestRoleOnly: "You are not an administrator. Are you looking for <a href='{{welcomeUrl}}'>the apps you can access</a>?",
113114
rolesInfo: "You have the following roles",
@@ -124,7 +125,9 @@
124125
authority: "Authority",
125126
endDate: "End date",
126127
expiryDays: "Expiry days",
127-
roleExpiryTooltip: "Sort on roles to see which roles will expire the soonest"
128+
roleExpiryTooltip: "Sort on roles to see which roles will expire the soonest",
129+
deleteFlash: "User {{name}} has been deleted",
130+
deleteConfirmation: "Are you absolutely sure you want to delete user {{name}}? There is no undo button."
128131
},
129132
role: {
130133
copyUrn: "Copy urn",

client/src/locale/nl.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ const nl = {
108108
applications: "Applicaties",
109109
noRolesInfo: "Je hebt geen rollen (je bent een super-user)",
110110
noRolesInstitutionAdmin: "Je bent instellingsadmin, maar hebt nog geen rollen (maar je hebt wel toegang tot je applicaties)",
111+
noRolesInstitutionAdminOther: "{{name}} is een instellingsadmin, maar heeft nog geen rollen (maar wel toegang tot applicaties)",
111112
noRolesNoApplicationsInstitutionAdmin: "Je bent instellingsadmin, maar je hebt geen rollen en je instelling heeft blijkbaar ook geen toegang tot applicaties.",
112113
guestRoleOnly: "Je bent geen admin. Ben je op zoek naar de <a href='{{welcomeUrl}}'>applicaties waar je toegang to hebt</a>?",
113114
rolesInfo: "Je hebt de volgende rollen",
@@ -124,7 +125,9 @@ const nl = {
124125
authority: "Autoriteit",
125126
endDate: "Einddatum",
126127
expiryDays: "Verloopdagen",
127-
roleExpiryTooltip: "Sorteer op rollen, om te zien welke rol het eerst zal verlopen"
128+
roleExpiryTooltip: "Sorteer op rollen, om te zien welke rol het eerst zal verlopen",
129+
deleteFlash: "Gebruiker {{name}} is verwijderd",
130+
deleteConfirmation: "Weet je heel zeker dat je gebruiker {{name}} wilt verwijderen? Er is geen undo functionaliteit."
128131
},
129132
role: {
130133
copyUrn: "Copy urn",

server/src/main/java/invite/api/UserController.java

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,22 @@
11
package invite.api;
22

3+
import com.fasterxml.jackson.core.JsonProcessingException;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import crypto.KeyStore;
36
import invite.config.Config;
47
import invite.exception.NotFoundException;
58
import invite.exception.UserRestrictionException;
69
import invite.manage.EntityType;
710
import invite.manage.Manage;
811
import invite.model.*;
912
import invite.provision.Provisioning;
13+
import invite.provision.ProvisioningService;
1014
import invite.provision.graph.GraphClient;
1115
import invite.repository.InvitationRepository;
1216
import invite.repository.RemoteProvisionedUserRepository;
1317
import invite.repository.RoleRepository;
1418
import invite.repository.UserRepository;
1519
import invite.security.UserPermissions;
16-
import com.fasterxml.jackson.core.JsonProcessingException;
17-
import com.fasterxml.jackson.databind.ObjectMapper;
18-
import crypto.KeyStore;
1920
import io.swagger.v3.oas.annotations.Parameter;
2021
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
2122
import jakarta.servlet.http.HttpServletRequest;
@@ -67,8 +68,7 @@ public class UserController {
6768
private final ObjectMapper objectMapper;
6869
private final RemoteProvisionedUserRepository remoteProvisionedUserRepository;
6970
private final GraphClient graphClient;
70-
private final RoleRepository roleRepository;
71-
71+
private final ProvisioningService provisioningService;
7272

7373
@Autowired
7474
public UserController(Config config,
@@ -81,15 +81,15 @@ public UserController(Config config,
8181
@Value("${config.eduid-idp-schac-home-organization}") String eduidIdpSchacHomeOrganization,
8282
@Value("${config.server-url}") String serverBaseURL,
8383
@Value("${voot.group_urn_domain}") String groupUrnPrefix,
84-
RoleRepository roleRepository) {
84+
ProvisioningService provisioningService) {
8585
this.invitationRepository = invitationRepository;
86+
this.provisioningService = provisioningService;
8687
this.config = config.withGroupUrnPrefix(groupUrnPrefix);
8788
this.userRepository = userRepository;
8889
this.objectMapper = objectMapper;
8990
this.manage = manage;
9091
this.remoteProvisionedUserRepository = remoteProvisionedUserRepository;
9192
this.graphClient = new GraphClient(serverBaseURL, eduidIdpSchacHomeOrganization, keyStore, objectMapper);
92-
this.roleRepository = roleRepository;
9393
}
9494

9595
@GetMapping("config")
@@ -209,6 +209,20 @@ public View msAcceptReturn(@PathVariable("manageId") String manageId, @PathVaria
209209
return new RedirectView(redirectReference.get());
210210
}
211211

212+
@DeleteMapping("/{userId}")
213+
public ResponseEntity<Void> delete(@PathVariable("userId") Long userId, @Parameter(hidden = true) User user) {
214+
User other = userRepository.findById(userId).orElseThrow(() -> new NotFoundException("User not found"));
215+
216+
LOG.debug(String.format("DELETE /users for user %s by %s",
217+
other.getEduPersonPrincipalName(), user.getEduPersonPrincipalName()));
218+
UserPermissions.assertSuperUser(user);
219+
220+
this.provisioningService.deleteUserRequest(user);
221+
userRepository.delete(other);
222+
return Results.deleteResult();
223+
}
224+
225+
212226
@PostMapping("error")
213227
public ResponseEntity<Map<String, Integer>> error(@RequestBody Map<String, Object> payload,
214228
@Parameter(hidden = true) User user) throws

server/src/test/java/invite/api/UserControllerTest.java

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package invite.api;
22

3+
import com.github.tomakehurst.wiremock.verification.LoggedRequest;
34
import invite.AbstractTest;
45
import invite.AccessCookieFilter;
56
import invite.DefaultPage;
@@ -9,7 +10,6 @@
910
import invite.model.RemoteProvisionedUser;
1011
import invite.model.User;
1112
import invite.model.UserRole;
12-
import com.github.tomakehurst.wiremock.verification.LoggedRequest;
1313
import io.restassured.common.mapper.TypeRef;
1414
import io.restassured.http.ContentType;
1515
import org.junit.jupiter.api.Test;
@@ -21,10 +21,7 @@
2121
import java.io.IOException;
2222
import java.net.URLDecoder;
2323
import java.nio.charset.StandardCharsets;
24-
import java.util.List;
25-
import java.util.Map;
26-
import java.util.Set;
27-
import java.util.UUID;
24+
import java.util.*;
2825
import java.util.stream.Stream;
2926

3027
import static com.github.tomakehurst.wiremock.client.WireMock.*;
@@ -223,6 +220,26 @@ void meWithImpersonation() throws Exception {
223220
assertEquals(MANAGE_SUB, user.getSub());
224221
}
225222

223+
@Test
224+
void delete() throws Exception {
225+
AccessCookieFilter accessCookieFilter = openIDConnectFlow("/api/v1/users/login", SUPER_SUB);
226+
227+
User institutionAdmin = userRepository.findBySubIgnoreCase(INSTITUTION_ADMIN_SUB).get();
228+
229+
given()
230+
.when()
231+
.filter(accessCookieFilter.cookieFilter())
232+
.header(accessCookieFilter.csrfToken().getHeaderName(), accessCookieFilter.csrfToken().getToken())
233+
.accept(ContentType.JSON)
234+
.contentType(ContentType.JSON)
235+
.pathParam("userId", institutionAdmin.getId())
236+
.delete("/api/v1/users/{userId}")
237+
.then()
238+
.statusCode(HttpStatus.NO_CONTENT.value());
239+
Optional<User> userOptional = userRepository.findBySubIgnoreCase(INSTITUTION_ADMIN_SUB);
240+
assertTrue(userOptional.isEmpty());
241+
}
242+
226243
@Test
227244
void meWithImpersonationInstitutionAdmin() throws Exception {
228245
AccessCookieFilter accessCookieFilter = openIDConnectFlow("/api/v1/users/login", SUPER_SUB);

0 commit comments

Comments
 (0)