Skip to content

Commit d6690bb

Browse files
gnugomeznetomi
andauthored
feat(admin): improve user management page (#1847)
* feat(admin): adding user role update endpoint & user search Assisted-By: anthropic:claude-opus-4-7[1m] * feat(admin): revamping publisher page with a user list, search & role update Assisted-By: github:claude-opus-4.6, github:claude-sonnet-4.6, anthropic:claude-opus-4-7[1m] * chore: updating changelog * fix: reducing search scope for admin user search query * refactor: bettern naming DTOs + including customer & namespace objects * fix(admin): make searchUsers transactional for lazy collection access * refactor(admin): simplify publisher component to avoid useEffect * add pr number to changelog * fix changelog after rebase * add @MutatingOperation to new post endpoint * chore: removing redundant status check for retry * feat: moving role filter next to the search * feat: simplify publisher admin search and user details * feat: consolidating user search api * chore: remove unused customers from user search response * feat: improving admin publisher UI * do not show publisher agreement status if its not enabled * rename AdminUser and AdminUsersResult to more meaningful name, add an alert when role change succeeded * explicitly set the role in the UserJson instead of setting it by default --------- Co-authored-by: Thomas Neidhart <thomas.neidhart@eclipse-foundation.org>
1 parent d5b27b5 commit d6690bb

33 files changed

Lines changed: 1406 additions & 193 deletions

server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1110,7 +1110,7 @@ private boolean isVerified(ExtensionVersion extVersion) {
11101110
}
11111111

11121112
var user = extVersion.getPublishedWith().getUser();
1113-
if (UserData.ROLE_PRIVILEGED.equals(user.getRole())) {
1113+
if (UserData.Role.PRIVILEGED.equals(user.getRole())) {
11141114
return true;
11151115
}
11161116

@@ -1124,7 +1124,7 @@ private boolean isVerified(ExtensionVersion extVersion, Map<Long, List<Namespace
11241124
}
11251125

11261126
var user = extVersion.getPublishedWith().getUser();
1127-
if(UserData.ROLE_PRIVILEGED.equals(user.getRole())) {
1127+
if(UserData.Role.PRIVILEGED.equals(user.getRole())) {
11281128
return true;
11291129
}
11301130

server/src/main/java/org/eclipse/openvsx/UserAPI.java

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,36 +9,70 @@
99
********************************************************************************/
1010
package org.eclipse.openvsx;
1111

12-
import jakarta.servlet.http.HttpServletRequest;
12+
import static org.eclipse.openvsx.entities.FileResource.CHANGELOG;
13+
import static org.eclipse.openvsx.entities.FileResource.DOWNLOAD;
14+
import static org.eclipse.openvsx.entities.FileResource.ICON;
15+
import static org.eclipse.openvsx.entities.FileResource.LICENSE;
16+
import static org.eclipse.openvsx.entities.FileResource.MANIFEST;
17+
import static org.eclipse.openvsx.entities.FileResource.README;
18+
import static org.eclipse.openvsx.entities.FileResource.VSIXMANIFEST;
19+
import static org.eclipse.openvsx.util.UrlUtil.createApiUrl;
20+
21+
import java.util.LinkedHashMap;
22+
import java.util.List;
23+
import java.util.Optional;
24+
import java.util.concurrent.TimeUnit;
25+
1326
import org.eclipse.openvsx.accesstoken.AccessTokenService;
1427
import org.eclipse.openvsx.eclipse.EclipseService;
15-
import org.eclipse.openvsx.entities.*;
16-
import org.eclipse.openvsx.settings.MutatingOperation;
17-
import org.eclipse.openvsx.json.*;
28+
import org.eclipse.openvsx.entities.ExtensionVersion;
29+
import org.eclipse.openvsx.entities.NamespaceMembership;
30+
import org.eclipse.openvsx.entities.ScanStatus;
31+
import org.eclipse.openvsx.entities.UsageStats;
32+
import org.eclipse.openvsx.entities.UserData;
33+
import org.eclipse.openvsx.json.AccessTokenJson;
34+
import org.eclipse.openvsx.json.CsrfTokenJson;
35+
import org.eclipse.openvsx.json.CustomerJson;
36+
import org.eclipse.openvsx.json.ErrorJson;
37+
import org.eclipse.openvsx.json.ExtensionJson;
38+
import org.eclipse.openvsx.json.LoginProvidersJson;
39+
import org.eclipse.openvsx.json.NamespaceDetailsJson;
40+
import org.eclipse.openvsx.json.NamespaceJson;
41+
import org.eclipse.openvsx.json.NamespaceMembershipListJson;
42+
import org.eclipse.openvsx.json.ResultJson;
43+
import org.eclipse.openvsx.json.TargetPlatformVersionJson;
44+
import org.eclipse.openvsx.json.UsageStatsListJson;
45+
import org.eclipse.openvsx.json.UserJson;
1846
import org.eclipse.openvsx.repositories.ExtensionScanRepository;
1947
import org.eclipse.openvsx.repositories.RepositoryService;
2048
import org.eclipse.openvsx.security.CodedAuthException;
49+
import org.eclipse.openvsx.settings.MutatingOperation;
2150
import org.eclipse.openvsx.storage.StorageUtilService;
22-
import org.eclipse.openvsx.util.*;
51+
import org.eclipse.openvsx.util.ErrorResultException;
52+
import org.eclipse.openvsx.util.NamingUtil;
53+
import org.eclipse.openvsx.util.NotFoundException;
54+
import org.eclipse.openvsx.util.TimeUtil;
55+
import org.eclipse.openvsx.util.UrlUtil;
2356
import org.slf4j.Logger;
2457
import org.slf4j.LoggerFactory;
58+
import org.springframework.data.domain.Pageable;
2559
import org.springframework.http.CacheControl;
2660
import org.springframework.http.HttpStatus;
2761
import org.springframework.http.MediaType;
2862
import org.springframework.http.ResponseEntity;
2963
import org.springframework.security.core.AuthenticationException;
3064
import org.springframework.security.web.WebAttributes;
3165
import org.springframework.security.web.csrf.CsrfToken;
32-
import org.springframework.web.bind.annotation.*;
66+
import org.springframework.web.bind.annotation.GetMapping;
67+
import org.springframework.web.bind.annotation.PathVariable;
68+
import org.springframework.web.bind.annotation.PostMapping;
69+
import org.springframework.web.bind.annotation.RequestBody;
70+
import org.springframework.web.bind.annotation.RequestParam;
71+
import org.springframework.web.bind.annotation.RestController;
3372
import org.springframework.web.multipart.MultipartFile;
3473
import org.springframework.web.server.ResponseStatusException;
3574

36-
import java.util.LinkedHashMap;
37-
import java.util.List;
38-
import java.util.concurrent.TimeUnit;
39-
40-
import static org.eclipse.openvsx.entities.FileResource.*;
41-
import static org.eclipse.openvsx.util.UrlUtil.createApiUrl;
75+
import jakarta.servlet.http.HttpServletRequest;
4276

4377
@RestController
4478
public class UserAPI {
@@ -123,7 +157,7 @@ public UserJson getUserData() {
123157
}
124158
var json = user.toUserJson();
125159
var serverUrl = UrlUtil.getBaseUrl();
126-
json.setRole(user.getRole());
160+
json.setRole(user.getRoleAsString());
127161
json.setTokensUrl(createApiUrl(serverUrl, "user", "tokens"));
128162
json.setCreateTokenUrl(createApiUrl(serverUrl, "user", "token", "create"));
129163
eclipse.enrichUserJsonWithPublisherAgreement(json, user);
@@ -540,7 +574,8 @@ public List<UserJson> getUsersStartWith(@PathVariable String name) {
540574
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
541575
}
542576

543-
return repositories.findUsersByLoginNameStartingWith(name, 5).stream()
577+
var pageable = Pageable.ofSize(5);
578+
return repositories.searchUsers(name, null, pageable).stream()
544579
.map(UserData::toUserJson)
545580
.toList();
546581
}
@@ -558,7 +593,7 @@ public ResponseEntity<UserJson> signPublisherAgreement() {
558593
var agreement = eclipse.signPublisherAgreement(user);
559594
var json = user.toUserJson();
560595
var serverUrl = UrlUtil.getBaseUrl();
561-
json.setRole(user.getRole());
596+
json.setRole(user.getRoleAsString());
562597
json.setTokensUrl(createApiUrl(serverUrl, "user", "tokens"));
563598
json.setCreateTokenUrl(createApiUrl(serverUrl, "user", "token", "create"));
564599
eclipse.enrichUserJson(json, user, agreement);

server/src/main/java/org/eclipse/openvsx/UserService.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ public UserData findLoggedInUser() {
9292
}
9393

9494
public boolean hasPublishPermission(UserData user, Namespace namespace) {
95-
if (UserData.ROLE_PRIVILEGED.equals(user.getRole())) {
95+
if (UserData.Role.PRIVILEGED.equals(user.getRole())) {
9696
// Privileged users can publish to every namespace.
9797
return true;
9898
}

server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,21 @@
2121
import org.eclipse.openvsx.entities.AdminStatistics;
2222
import org.eclipse.openvsx.entities.NamespaceMembership;
2323
import org.eclipse.openvsx.entities.PersistedLog;
24-
import org.eclipse.openvsx.json.*;
25-
import org.eclipse.openvsx.settings.MutatingOperation;
24+
import org.eclipse.openvsx.json.AdminStatisticsJson;
25+
import org.eclipse.openvsx.json.ChangeNamespaceJson;
26+
import org.eclipse.openvsx.json.ExtensionJson;
27+
import org.eclipse.openvsx.json.NamespaceJson;
28+
import org.eclipse.openvsx.json.NamespaceMembershipListJson;
29+
import org.eclipse.openvsx.json.PersistedLogJson;
30+
import org.eclipse.openvsx.json.ResultJson;
31+
import org.eclipse.openvsx.json.SettingsJson;
32+
import org.eclipse.openvsx.json.StatsJson;
33+
import org.eclipse.openvsx.json.TargetPlatformVersionJson;
34+
import org.eclipse.openvsx.json.UserPublishInfoJson;
35+
import org.eclipse.openvsx.json.UserRelationshipsJson;
2636
import org.eclipse.openvsx.repositories.RepositoryService;
2737
import org.eclipse.openvsx.search.SearchUtilService;
38+
import org.eclipse.openvsx.settings.MutatingOperation;
2839
import org.eclipse.openvsx.settings.SettingsService;
2940
import org.eclipse.openvsx.util.ErrorResultException;
3041
import org.eclipse.openvsx.util.LogService;
@@ -35,11 +46,19 @@
3546
import org.springframework.data.domain.Page;
3647
import org.springframework.data.domain.Pageable;
3748
import org.springframework.data.util.Streamable;
38-
import org.springframework.data.web.PageableDefault;
3949
import org.springframework.http.HttpStatus;
4050
import org.springframework.http.MediaType;
4151
import org.springframework.http.ResponseEntity;
42-
import org.springframework.web.bind.annotation.*;
52+
import org.springframework.web.bind.annotation.CrossOrigin;
53+
import org.springframework.web.bind.annotation.DeleteMapping;
54+
import org.springframework.web.bind.annotation.GetMapping;
55+
import org.springframework.web.bind.annotation.PathVariable;
56+
import org.springframework.web.bind.annotation.PostMapping;
57+
import org.springframework.web.bind.annotation.PutMapping;
58+
import org.springframework.web.bind.annotation.RequestBody;
59+
import org.springframework.web.bind.annotation.RequestMapping;
60+
import org.springframework.web.bind.annotation.RequestParam;
61+
import org.springframework.web.bind.annotation.RestController;
4362
import org.springframework.web.server.ResponseStatusException;
4463

4564
import io.swagger.v3.oas.annotations.Operation;
@@ -154,6 +173,24 @@ public ResponseEntity<StatsJson> getStats() {
154173
}
155174
}
156175

176+
@GetMapping(
177+
path = "/user/search",
178+
produces = MediaType.APPLICATION_JSON_VALUE
179+
)
180+
public ResponseEntity<Page<UserRelationshipsJson>> getUsers(
181+
@RequestParam(name = "query", required = false) String query,
182+
Pageable pageable,
183+
@RequestParam(name = "role", required = false) String role
184+
) {
185+
try {
186+
admins.checkAdminUser();
187+
return ResponseEntity.ok(admins.searchUsers(query, role, pageable));
188+
} catch (ErrorResultException exc) {
189+
var status = exc.getStatus() != null ? exc.getStatus() : HttpStatus.BAD_REQUEST;
190+
throw new ResponseStatusException(status);
191+
}
192+
}
193+
157194
@GetMapping(
158195
path = "/log",
159196
produces = MediaType.TEXT_PLAIN_VALUE
@@ -373,6 +410,29 @@ public ResponseEntity<ResultJson> deleteReview(
373410
}
374411
}
375412

413+
@PostMapping(
414+
path = "/user/{provider}/{loginName}/role",
415+
produces = MediaType.APPLICATION_JSON_VALUE
416+
)
417+
@MutatingOperation
418+
public ResponseEntity<ResultJson> updateUserRole(
419+
@PathVariable String provider,
420+
@PathVariable String loginName,
421+
@RequestParam
422+
@Parameter(
423+
description = "The role to assign to the user, or 'none' to remove their role",
424+
schema = @Schema(allowableValues = {"admin", "privileged", "none"})
425+
)
426+
String role
427+
) {
428+
try {
429+
var adminUser = admins.checkAdminUser();
430+
return ResponseEntity.ok(admins.updateUserRole(provider, loginName, role, adminUser));
431+
} catch (ErrorResultException exc) {
432+
return exc.toResponseEntity();
433+
}
434+
}
435+
376436
@GetMapping(
377437
path = "/namespace/{namespaceName}",
378438
produces = MediaType.APPLICATION_JSON_VALUE

server/src/main/java/org/eclipse/openvsx/admin/AdminService.java

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
import org.eclipse.openvsx.json.NamespaceJson;
4646
import org.eclipse.openvsx.json.ResultJson;
4747
import org.eclipse.openvsx.json.TargetPlatformVersionJson;
48+
import org.eclipse.openvsx.json.UserRelationshipsJson;
4849
import org.eclipse.openvsx.json.UserPublishInfoJson;
4950
import org.eclipse.openvsx.mail.MailService;
5051
import org.eclipse.openvsx.migration.HandlerJobRequest;
@@ -62,6 +63,8 @@
6263
import org.jobrunr.scheduling.cron.Cron;
6364
import org.springframework.boot.context.event.ApplicationStartedEvent;
6465
import org.springframework.context.event.EventListener;
66+
import org.springframework.data.domain.Page;
67+
import org.springframework.data.domain.Pageable;
6568
import org.springframework.http.HttpStatus;
6669
import org.springframework.stereotype.Component;
6770

@@ -469,7 +472,9 @@ public UserPublishInfoJson getUserPublishInfo(String provider, String loginName)
469472
}
470473

471474
var userPublishInfo = new UserPublishInfoJson();
472-
userPublishInfo.setUser(user.toUserJson());
475+
var userJson = user.toUserJson();
476+
userJson.setRole(user.getRoleAsString());
477+
userPublishInfo.setUser(userJson);
473478
eclipse.adminEnrichUserJson(userPublishInfo.getUser(), user);
474479
userPublishInfo.setActiveAccessTokenNum((int) repositories.countActiveAccessTokens(user));
475480
var extVersions = repositories.findLatestVersions(user);
@@ -493,6 +498,42 @@ public UserPublishInfoJson getUserPublishInfo(String provider, String loginName)
493498
return userPublishInfo;
494499
}
495500

501+
@Transactional
502+
public Page<UserRelationshipsJson> searchUsers(String search, String role, Pageable pageable) {
503+
return repositories.searchUsers(search, role, pageable)
504+
.map(user -> {
505+
var json = new UserRelationshipsJson();
506+
var userJson = user.toUserJson();
507+
userJson.setRole(user.getRoleAsString());
508+
json.setUser(userJson);
509+
json.setNamespaces(repositories.findMemberships(user).stream()
510+
.map(membership -> membership.getNamespace().toNamespaceDetailsJson())
511+
.toList());
512+
return json;
513+
});
514+
}
515+
516+
@Transactional(rollbackOn = ErrorResultException.class)
517+
public ResultJson updateUserRole(String provider, String loginName, String role, UserData admin) {
518+
var user = repositories.findUserByLoginName(provider, loginName);
519+
if (user == null) {
520+
throw new ErrorResultException(userNotFoundMessage(provider + "/" + loginName), HttpStatus.NOT_FOUND);
521+
}
522+
523+
var updatedRole = "none".equalsIgnoreCase(role) ? null : parseRole(role);
524+
if (Objects.equals(user.getRole(), updatedRole)) {
525+
throw new ErrorResultException("User " + provider + "/" + loginName + " already has the role " + user.getRole() + ".");
526+
}
527+
528+
user.setRole(updatedRole);
529+
var message = updatedRole == null
530+
? "Removed role from user " + provider + "/" + loginName + "."
531+
: "Updated role for user " + provider + "/" + loginName + " to " + updatedRole + ".";
532+
var result = ResultJson.success(message);
533+
logs.logAction(admin, result);
534+
return result;
535+
}
536+
496537
@Transactional(rollbackOn = ErrorResultException.class)
497538
public ResultJson revokePublisherContributions(String provider, String loginName, UserData admin) {
498539
var user = repositories.findUserByLoginName(provider, loginName);
@@ -565,12 +606,20 @@ public UserData checkAdminUser(String tokenValue) {
565606
}
566607

567608
private UserData checkAdminUser(UserData user) {
568-
if (user == null || !UserData.ROLE_ADMIN.equals(user.getRole())) {
609+
if (user == null || !UserData.Role.ADMIN.equals(user.getRole())) {
569610
throw new ErrorResultException("Administration role is required.", HttpStatus.FORBIDDEN);
570611
}
571612
return user;
572613
}
573614

615+
private UserData.Role parseRole(String role) {
616+
try {
617+
return UserData.Role.valueOfIgnoreCase(role);
618+
} catch (IllegalArgumentException ignored) {
619+
throw new ErrorResultException("Invalid role: " + role, HttpStatus.BAD_REQUEST);
620+
}
621+
}
622+
574623
public AdminStatistics getAdminStatistics(int year, int month) throws ErrorResultException {
575624
validateYearAndMonth(year, month);
576625
var statistics = repositories.findAdminStatisticsByYearAndMonth(year, month);

server/src/main/java/org/eclipse/openvsx/entities/Customer.java

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,25 @@
1212
*****************************************************************************/
1313
package org.eclipse.openvsx.entities;
1414

15-
import jakarta.persistence.*;
16-
import org.eclipse.openvsx.json.CustomerJson;
17-
1815
import java.io.Serial;
1916
import java.io.Serializable;
2017
import java.util.Collections;
2118
import java.util.List;
2219
import java.util.Objects;
2320

21+
import org.eclipse.openvsx.json.CustomerJson;
22+
23+
import jakarta.persistence.Column;
24+
import jakarta.persistence.Convert;
25+
import jakarta.persistence.Entity;
26+
import jakarta.persistence.EnumType;
27+
import jakarta.persistence.Enumerated;
28+
import jakarta.persistence.GeneratedValue;
29+
import jakarta.persistence.Id;
30+
import jakarta.persistence.JoinColumn;
31+
import jakarta.persistence.ManyToOne;
32+
import jakarta.persistence.SequenceGenerator;
33+
2434
@Entity
2535
public class Customer implements Serializable {
2636

server/src/main/java/org/eclipse/openvsx/entities/CustomerMembership.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414

1515
import jakarta.persistence.*;
1616
import org.eclipse.openvsx.json.CustomerMembershipJson;
17-
import org.eclipse.openvsx.json.NamespaceMembershipJson;
1817

1918
import java.io.Serial;
2019
import java.io.Serializable;

0 commit comments

Comments
 (0)