diff --git a/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java b/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java index ad2592a39..bd374e61d 100644 --- a/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java +++ b/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java @@ -1110,7 +1110,7 @@ private boolean isVerified(ExtensionVersion extVersion) { } var user = extVersion.getPublishedWith().getUser(); - if (UserData.ROLE_PRIVILEGED.equals(user.getRole())) { + if (UserData.Role.PRIVILEGED.equals(user.getRole())) { return true; } @@ -1124,7 +1124,7 @@ private boolean isVerified(ExtensionVersion extVersion, Map signPublisherAgreement() { var agreement = eclipse.signPublisherAgreement(user); var json = user.toUserJson(); var serverUrl = UrlUtil.getBaseUrl(); - json.setRole(user.getRole()); json.setTokensUrl(createApiUrl(serverUrl, "user", "tokens")); json.setCreateTokenUrl(createApiUrl(serverUrl, "user", "token", "create")); eclipse.enrichUserJson(json, user, agreement); diff --git a/server/src/main/java/org/eclipse/openvsx/UserService.java b/server/src/main/java/org/eclipse/openvsx/UserService.java index 95b0d3a22..4753ae9da 100644 --- a/server/src/main/java/org/eclipse/openvsx/UserService.java +++ b/server/src/main/java/org/eclipse/openvsx/UserService.java @@ -92,7 +92,7 @@ public UserData findLoggedInUser() { } public boolean hasPublishPermission(UserData user, Namespace namespace) { - if (UserData.ROLE_PRIVILEGED.equals(user.getRole())) { + if (UserData.Role.PRIVILEGED.equals(user.getRole())) { // Privileged users can publish to every namespace. return true; } diff --git a/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java b/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java index 1ea05f8a0..a24fcd557 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java @@ -30,6 +30,7 @@ import org.eclipse.openvsx.json.ResultJson; import org.eclipse.openvsx.json.StatsJson; import org.eclipse.openvsx.json.TargetPlatformVersionJson; +import org.eclipse.openvsx.json.UserRelationshipsJson; import org.eclipse.openvsx.json.UserPublishInfoJson; import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.search.SearchUtilService; @@ -163,6 +164,24 @@ public ResponseEntity getStats() { } } + @GetMapping( + path = "/admin/users", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity> getUsers( + Pageable pageable, + @RequestParam(name = "search", required = false) String search, + @RequestParam(name = "role", required = false) String role + ) { + try { + admins.checkAdminUser(); + return ResponseEntity.ok(admins.searchUsers(search, role, pageable)); + } catch (ErrorResultException exc) { + var status = exc.getStatus() != null ? exc.getStatus() : HttpStatus.BAD_REQUEST; + throw new ResponseStatusException(status); + } + } + @GetMapping( path = "/admin/log", produces = MediaType.TEXT_PLAIN_VALUE @@ -375,6 +394,28 @@ public ResponseEntity deleteReview( } } + @PostMapping( + path = "/admin/user/{provider}/{loginName}/role", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity updateUserRole( + @PathVariable String provider, + @PathVariable String loginName, + @RequestParam + @Parameter( + description = "The role to assign to the user, or 'none' to remove their role", + schema = @Schema(allowableValues = {"admin", "privileged", "none"}) + ) + String role + ) { + try { + var adminUser = admins.checkAdminUser(); + return ResponseEntity.ok(admins.updateUserRole(provider, loginName, role, adminUser)); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(); + } + } + @GetMapping( path = "/admin/namespace/{namespaceName}", produces = MediaType.APPLICATION_JSON_VALUE diff --git a/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java b/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java index 6ca170c68..b796e5c2d 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java @@ -45,6 +45,7 @@ import org.eclipse.openvsx.json.NamespaceJson; import org.eclipse.openvsx.json.ResultJson; import org.eclipse.openvsx.json.TargetPlatformVersionJson; +import org.eclipse.openvsx.json.UserRelationshipsJson; import org.eclipse.openvsx.json.UserPublishInfoJson; import org.eclipse.openvsx.mail.MailService; import org.eclipse.openvsx.migration.HandlerJobRequest; @@ -62,6 +63,8 @@ import org.jobrunr.scheduling.cron.Cron; import org.springframework.boot.context.event.ApplicationStartedEvent; import org.springframework.context.event.EventListener; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; @@ -482,6 +485,42 @@ public UserPublishInfoJson getUserPublishInfo(String provider, String loginName) return userPublishInfo; } + public Page searchUsers(String search, String role, Pageable pageable) { + return repositories.searchUsers(search, role, pageable) + .map(user -> { + var json = new UserRelationshipsJson(); + json.setUser(user.toUserJson()); + json.setNamespaces(repositories.findMemberships(user).stream() + .map(membership -> membership.getNamespace().toNamespaceDetailsJson()) + .toList()); + json.setCustomers(repositories.findCustomerMemberships(user).stream() + .map(membership -> membership.getCustomer().toJson()) + .toList()); + return json; + }); + } + + @Transactional(rollbackOn = ErrorResultException.class) + public ResultJson updateUserRole(String provider, String loginName, String role, UserData admin) { + var user = repositories.findUserByLoginName(provider, loginName); + if (user == null) { + throw new ErrorResultException(userNotFoundMessage(provider + "/" + loginName), HttpStatus.NOT_FOUND); + } + + var updatedRole = "none".equalsIgnoreCase(role) ? null : parseRole(role); + if (Objects.equals(user.getRole(), updatedRole)) { + return ResultJson.success("User " + provider + "/" + loginName + " already has the role " + user.getRole() + "."); + } + + user.setRole(updatedRole); + var message = updatedRole == null + ? "Removed role from user " + provider + "/" + loginName + "." + : "Updated role for user " + provider + "/" + loginName + " to " + updatedRole + "."; + var result = ResultJson.success(message); + logs.logAction(admin, result); + return result; + } + @Transactional(rollbackOn = ErrorResultException.class) public ResultJson revokePublisherContributions(String provider, String loginName, UserData admin) { var user = repositories.findUserByLoginName(provider, loginName); @@ -554,12 +593,20 @@ public UserData checkAdminUser(String tokenValue) { } private UserData checkAdminUser(UserData user) { - if (user == null || !UserData.ROLE_ADMIN.equals(user.getRole())) { + if (user == null || !UserData.Role.ADMIN.equals(user.getRole())) { throw new ErrorResultException("Administration role is required.", HttpStatus.FORBIDDEN); } return user; } + private UserData.Role parseRole(String role) { + try { + return UserData.Role.valueOfIgnoreCase(role); + } catch (IllegalArgumentException ignored) { + throw new ErrorResultException("Invalid role: " + role, HttpStatus.BAD_REQUEST); + } + } + public AdminStatistics getAdminStatistics(int year, int month) throws ErrorResultException { validateYearAndMonth(year, month); var statistics = repositories.findAdminStatisticsByYearAndMonth(year, month); diff --git a/server/src/main/java/org/eclipse/openvsx/entities/Customer.java b/server/src/main/java/org/eclipse/openvsx/entities/Customer.java index 88367b409..2ce97e007 100644 --- a/server/src/main/java/org/eclipse/openvsx/entities/Customer.java +++ b/server/src/main/java/org/eclipse/openvsx/entities/Customer.java @@ -12,15 +12,25 @@ *****************************************************************************/ package org.eclipse.openvsx.entities; -import jakarta.persistence.*; -import org.eclipse.openvsx.json.CustomerJson; - import java.io.Serial; import java.io.Serializable; import java.util.Collections; import java.util.List; import java.util.Objects; +import org.eclipse.openvsx.json.CustomerJson; + +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.SequenceGenerator; + @Entity public class Customer implements Serializable { diff --git a/server/src/main/java/org/eclipse/openvsx/entities/CustomerMembership.java b/server/src/main/java/org/eclipse/openvsx/entities/CustomerMembership.java index ff475d30d..73da1242b 100644 --- a/server/src/main/java/org/eclipse/openvsx/entities/CustomerMembership.java +++ b/server/src/main/java/org/eclipse/openvsx/entities/CustomerMembership.java @@ -14,7 +14,6 @@ import jakarta.persistence.*; import org.eclipse.openvsx.json.CustomerMembershipJson; -import org.eclipse.openvsx.json.NamespaceMembershipJson; import java.io.Serial; import java.io.Serializable; diff --git a/server/src/main/java/org/eclipse/openvsx/entities/UserData.java b/server/src/main/java/org/eclipse/openvsx/entities/UserData.java index add5ffbaa..598261121 100644 --- a/server/src/main/java/org/eclipse/openvsx/entities/UserData.java +++ b/server/src/main/java/org/eclipse/openvsx/entities/UserData.java @@ -9,13 +9,21 @@ ********************************************************************************/ package org.eclipse.openvsx.entities; -import jakarta.persistence.*; -import org.eclipse.openvsx.json.UserJson; - import java.io.Serial; import java.io.Serializable; import java.util.List; import java.util.Objects; +import java.util.Optional; + +import org.eclipse.openvsx.json.UserJson; + +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.SequenceGenerator; @Entity public class UserData implements Serializable { @@ -23,8 +31,20 @@ public class UserData implements Serializable { @Serial private static final long serialVersionUID = 1L; - public static final String ROLE_ADMIN = "admin"; - public static final String ROLE_PRIVILEGED = "privileged"; + public enum Role { + ADMIN, + PRIVILEGED; + + public static Role valueOfIgnoreCase(String value) { + if (value == null) return null; + return Role.valueOf(value.trim().toUpperCase()); + } + + @Override + public String toString() { + return name().toLowerCase(); + } + } @Id @GeneratedValue(generator = "userDataSeq") @@ -32,7 +52,8 @@ public class UserData implements Serializable { private long id; @Column(length = 32) - private String role; + @Convert(converter = UserRoleConverter.class) + private Role role; private String loginName; @@ -72,6 +93,7 @@ public UserJson toUserJson() { json.setAvatarUrl(this.getAvatarUrl()); json.setHomepage(this.getProviderUrl()); json.setProvider(this.getProvider()); + json.setRole(Optional.ofNullable(this.getRole()).map(Role::toString).orElse(null)); return json; } @@ -83,11 +105,11 @@ public void setId(long id) { this.id = id; } - public String getRole() { + public Role getRole() { return role; } - public void setRole(String role) { + public void setRole(Role role) { this.role = role; } diff --git a/server/src/main/java/org/eclipse/openvsx/entities/UserRoleConverter.java b/server/src/main/java/org/eclipse/openvsx/entities/UserRoleConverter.java new file mode 100644 index 000000000..fc1ea4d00 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/entities/UserRoleConverter.java @@ -0,0 +1,28 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.entities; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter +public class UserRoleConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(UserData.Role role) { + return role != null ? role.toString() : null; + } + + @Override + public UserData.Role convertToEntityAttribute(String value) { + return UserData.Role.valueOfIgnoreCase(value); + } + +} \ No newline at end of file diff --git a/server/src/main/java/org/eclipse/openvsx/json/UserRelationshipsJson.java b/server/src/main/java/org/eclipse/openvsx/json/UserRelationshipsJson.java new file mode 100644 index 000000000..ba5c4d020 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/UserRelationshipsJson.java @@ -0,0 +1,48 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ +package org.eclipse.openvsx.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +import java.util.List; + +@JsonInclude(Include.NON_NULL) +public class UserRelationshipsJson { + + private UserJson user; + private List namespaces; + private List customers; + + public UserJson getUser() { + return user; + } + + public List getNamespaces() { + return namespaces; + } + + public List getCustomers() { + return customers; + } + + public void setUser(UserJson user) { + this.user = user; + } + + public void setNamespaces(List namespaces) { + this.namespaces = namespaces; + } + + public void setCustomers(List customers) { + this.customers = customers; + } + +} \ No newline at end of file diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionVersionJooqRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionVersionJooqRepository.java index 3595eb8c3..1476aee69 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionVersionJooqRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionVersionJooqRepository.java @@ -466,7 +466,7 @@ private ExtensionVersion toExtensionVersionFull( var user = new UserData(); user.setId(row.get(USER_DATA.ID)); - user.setRole(row.get(USER_DATA.ROLE)); + user.setRole(UserData.Role.valueOfIgnoreCase(row.get(USER_DATA.ROLE))); user.setLoginName(row.get(USER_DATA.LOGIN_NAME)); user.setFullName(row.get(USER_DATA.FULL_NAME)); user.setAvatarUrl(row.get(USER_DATA.AVATAR_URL)); diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java index 4a0fcbc12..79e65f109 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java @@ -81,6 +81,7 @@ public class RepositoryService { private final UsageStatsRepository usageStatsRepository; private final RateLimitTokenRepository rateLimitTokenRepository; private final DailyUsageStatsRepository dailyUsageStatsRepository; + private final UserDataJooqRepository userDataJooqRepo; public RepositoryService( NamespaceRepository namespaceRepo, @@ -117,7 +118,8 @@ public RepositoryService( CustomerMembershipRepository customerMembershipRepo, UsageStatsRepository usageStatsRepository, RateLimitTokenRepository rateLimitTokenRepository, - DailyUsageStatsRepository dailyUsageStatsRepository + DailyUsageStatsRepository dailyUsageStatsRepository, + UserDataJooqRepository userDataJooqRepo ) { this.namespaceRepo = namespaceRepo; this.namespaceJooqRepo = namespaceJooqRepo; @@ -154,6 +156,7 @@ public RepositoryService( this.usageStatsRepository = usageStatsRepository; this.rateLimitTokenRepository = rateLimitTokenRepository; this.dailyUsageStatsRepository = dailyUsageStatsRepository; + this.userDataJooqRepo = userDataJooqRepo; } public Namespace findNamespace(String name) { @@ -344,6 +347,10 @@ public long countUsers() { return userDataRepo.count(); } + public Page searchUsers(String search, String role, Pageable pageable) { + return userDataJooqRepo.findUsers(search, role, pageable); + } + public NamespaceMembership findMembership(UserData user, Namespace namespace) { return membershipRepo.findByUserAndNamespace(user, namespace); } diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/UserDataJooqRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/UserDataJooqRepository.java new file mode 100644 index 000000000..06f17d7e9 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/repositories/UserDataJooqRepository.java @@ -0,0 +1,110 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.repositories; + +import static org.eclipse.openvsx.jooq.Tables.CUSTOMER; +import static org.eclipse.openvsx.jooq.Tables.CUSTOMER_MEMBERSHIP; +import static org.eclipse.openvsx.jooq.Tables.NAMESPACE; +import static org.eclipse.openvsx.jooq.Tables.NAMESPACE_MEMBERSHIP; +import static org.eclipse.openvsx.jooq.Tables.USER_DATA; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import org.apache.commons.lang3.StringUtils; +import org.eclipse.openvsx.entities.UserData; +import org.jooq.Condition; +import org.jooq.DSLContext; +import org.jooq.Record; +import org.jooq.SelectQuery; +import org.jooq.impl.DSL; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +@Component +public class UserDataJooqRepository { + + private final DSLContext dsl; + + public UserDataJooqRepository(DSLContext dsl) { + this.dsl = dsl; + } + + public Page findUsers(String search, String role, Pageable pageable) { + var conditions = buildConditions(search, role); + + var total = dsl.selectCount() + .from(USER_DATA) + .where(conditions) + .fetchOne(0, long.class); + + var query = baseQuery(); + query.addConditions(conditions); + query.addOrderBy(DSL.lower(USER_DATA.LOGIN_NAME).asc()); + query.addOffset((int) pageable.getOffset()); + query.addLimit(pageable.getPageSize()); + var content = query.fetch(this::toUserData); + + return new PageImpl<>(content, pageable, total); + } + + private SelectQuery baseQuery() { + var query = dsl.selectQuery(); + query.addSelect( + USER_DATA.ID, + USER_DATA.LOGIN_NAME, + USER_DATA.FULL_NAME, + USER_DATA.AVATAR_URL, + USER_DATA.PROVIDER, + USER_DATA.PROVIDER_URL, + USER_DATA.ROLE + ); + query.addFrom(USER_DATA); + return query; + } + + private List buildConditions(String search, String role) { + var conditions = new ArrayList(); + conditions.add(USER_DATA.PROVIDER.isNotNull()); + + if (StringUtils.isNotBlank(role)) { + if ("none".equalsIgnoreCase(role)) { + conditions.add(USER_DATA.ROLE.isNull()); + } else { + conditions.add(USER_DATA.ROLE.equalIgnoreCase(role)); + } + } + + if (StringUtils.isNotBlank(search)) { + var like = "%" + search.toLowerCase(Locale.ROOT) + "%"; + var searchCondition = DSL.lower(USER_DATA.LOGIN_NAME).like(like) + .or(DSL.lower(USER_DATA.FULL_NAME).like(like)); + + conditions.add(searchCondition); + } + + return conditions; + } + + private UserData toUserData(Record row) { + var user = new UserData(); + user.setId(row.get(USER_DATA.ID)); + user.setLoginName(row.get(USER_DATA.LOGIN_NAME)); + user.setFullName(row.get(USER_DATA.FULL_NAME)); + user.setAvatarUrl(row.get(USER_DATA.AVATAR_URL)); + user.setProvider(row.get(USER_DATA.PROVIDER)); + user.setProviderUrl(row.get(USER_DATA.PROVIDER_URL)); + user.setRole(UserData.Role.valueOfIgnoreCase(row.get(USER_DATA.ROLE))); + return user; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/security/OAuth2UserServices.java b/server/src/main/java/org/eclipse/openvsx/security/OAuth2UserServices.java index 782d5ad06..05c7e885f 100644 --- a/server/src/main/java/org/eclipse/openvsx/security/OAuth2UserServices.java +++ b/server/src/main/java/org/eclipse/openvsx/security/OAuth2UserServices.java @@ -34,9 +34,6 @@ import java.util.Collection; import static java.util.Collections.emptyList; -import static java.util.Objects.requireNonNullElse; -import static org.eclipse.openvsx.entities.UserData.ROLE_ADMIN; -import static org.eclipse.openvsx.entities.UserData.ROLE_PRIVILEGED; import static org.eclipse.openvsx.security.CodedAuthException.*; import static org.springframework.security.core.authority.AuthorityUtils.createAuthorityList; @@ -147,10 +144,13 @@ private IdPrincipal loadEclipseUser(OAuth2UserRequest userRequest) { } private Collection getAuthorities(UserData userData) { - return switch (requireNonNullElse(userData.getRole(), "")) { - case ROLE_ADMIN -> createAuthorityList("ROLE_ADMIN"); - case ROLE_PRIVILEGED -> createAuthorityList("ROLE_PRIVILEGED"); - default -> emptyList(); + var role = userData.getRole(); + if (role == null) { + return emptyList(); + } + return switch (role) { + case ADMIN -> createAuthorityList("ROLE_ADMIN"); + case PRIVILEGED -> createAuthorityList("ROLE_PRIVILEGED"); }; } } diff --git a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java index 0f8915e2d..055f82084 100644 --- a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java @@ -2433,7 +2433,7 @@ private void mockForPublish(String mode) { Mockito.when(repositories.hasMemberships(namespace, NamespaceMembership.ROLE_OWNER)) .thenReturn(true); if (mode.equals("privileged")) { - token.getUser().setRole(UserData.ROLE_PRIVILEGED); + token.getUser().setRole(UserData.Role.PRIVILEGED); // Mock findMemberships(user) for similarity check - privileged user might have memberships Mockito.when(repositories.findMemberships(token.getUser())) .thenReturn(Streamable.empty()); diff --git a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java index b6ddd51af..ad95c3f94 100644 --- a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java @@ -657,6 +657,108 @@ void testDeleteNamespaceNotEmpty() throws Exception { .andExpect(status().isBadRequest()); } + @Test + void testGetUsersNotLoggedIn() throws Exception { + mockMvc.perform(get("/admin/users") + .with(csrf().asHeader())) + .andExpect(status().isForbidden()); + } + + @Test + void testGetUsersNotAdmin() throws Exception { + mockNormalUser(); + mockMvc.perform(get("/admin/users") + .with(user("test_user")) + .with(csrf().asHeader())) + .andExpect(status().isForbidden()); + } + + @Test + void testUpdateUserRoleNotLoggedIn() throws Exception { + mockMvc.perform(post("/admin/user/{provider}/{loginName}/role", "github", "test") + .param("role", "admin") + .with(csrf().asHeader())) + .andExpect(status().isForbidden()); + } + + @Test + void testUpdateUserRoleNotAdmin() throws Exception { + mockNormalUser(); + mockMvc.perform(post("/admin/user/{provider}/{loginName}/role", "github", "test") + .param("role", "admin") + .with(user("test_user")) + .with(csrf().asHeader())) + .andExpect(status().isForbidden()); + } + + @Test + void testUpdateUserRole() throws Exception { + mockAdminUser(); + var user = new UserData(); + user.setLoginName("test"); + user.setProvider("github"); + Mockito.when(repositories.findUserByLoginName("github", "test")) + .thenReturn(user); + + mockMvc.perform(post("/admin/user/{provider}/{loginName}/role", "github", "test") + .param("role", "admin") + .with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN")))) + .with(csrf().asHeader())) + .andExpect(status().isOk()) + .andExpect(content().json(successJson("Updated role for user github/test to admin."))); + + assertThat(user.getRole().toString()).isEqualTo("admin"); + } + + @Test + void testUpdateUserRoleRemove() throws Exception { + mockAdminUser(); + var user = new UserData(); + user.setLoginName("test"); + user.setProvider("github"); + user.setRole(UserData.Role.ADMIN); + Mockito.when(repositories.findUserByLoginName("github", "test")) + .thenReturn(user); + + mockMvc.perform(post("/admin/user/{provider}/{loginName}/role", "github", "test") + .param("role", "none") + .with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN")))) + .with(csrf().asHeader())) + .andExpect(status().isOk()) + .andExpect(content().json(successJson("Removed role from user github/test."))); + + assertThat(user.getRole()).isNull(); + } + + @Test + void testUpdateUserRoleInvalid() throws Exception { + mockAdminUser(); + var user = new UserData(); + user.setLoginName("test"); + user.setProvider("github"); + Mockito.when(repositories.findUserByLoginName("github", "test")) + .thenReturn(user); + + mockMvc.perform(post("/admin/user/{provider}/{loginName}/role", "github", "test") + .param("role", "invalid_role") + .with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN")))) + .with(csrf().asHeader())) + .andExpect(status().isBadRequest()); + } + + @Test + void testUpdateUserRoleNotFound() throws Exception { + mockAdminUser(); + Mockito.when(repositories.findUserByLoginName("github", "unknown")) + .thenReturn(null); + + mockMvc.perform(post("/admin/user/{provider}/{loginName}/role", "github", "unknown") + .param("role", "admin") + .with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN")))) + .with(csrf().asHeader())) + .andExpect(status().isNotFound()); + } + @Test void testGetUserPublishInfoNotLoggedIn() throws Exception { mockNamespace(); @@ -1312,7 +1414,7 @@ void testDeleteReviewNonExistingReview() throws Exception { private PersonalAccessToken mockAdminToken() { var user = new UserData(); - user.setRole(UserData.ROLE_ADMIN); + user.setRole(UserData.Role.ADMIN); var tokenValue = "admin_token"; var token = new PersonalAccessToken(); @@ -1326,7 +1428,7 @@ private PersonalAccessToken mockAdminToken() { private PersonalAccessToken mockNonAdminToken() { var user = new UserData(); - user.setRole(UserData.ROLE_PRIVILEGED); + user.setRole(UserData.Role.PRIVILEGED); var tokenValue = "normal_token"; var token = new PersonalAccessToken(); @@ -1350,7 +1452,7 @@ private UserData mockAdminUser() { var userData = new UserData(); userData.setLoginName("admin_user"); userData.setFullName("Admin User"); - userData.setRole(UserData.ROLE_ADMIN); + userData.setRole(UserData.Role.ADMIN); Mockito.doReturn(userData).when(users).findLoggedInUser(); return userData; } diff --git a/server/src/test/java/org/eclipse/openvsx/admin/ScanAPITest.java b/server/src/test/java/org/eclipse/openvsx/admin/ScanAPITest.java index 0d0aef6b7..f382f289d 100644 --- a/server/src/test/java/org/eclipse/openvsx/admin/ScanAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/admin/ScanAPITest.java @@ -461,7 +461,7 @@ static ExtensionVersion version(long id, String displayName) { static org.eclipse.openvsx.entities.UserData adminUser() { var user = new org.eclipse.openvsx.entities.UserData(); - user.setRole(org.eclipse.openvsx.entities.UserData.ROLE_ADMIN); + user.setRole(org.eclipse.openvsx.entities.UserData.Role.ADMIN); return user; } } diff --git a/server/src/test/java/org/eclipse/openvsx/cache/CacheServiceTest.java b/server/src/test/java/org/eclipse/openvsx/cache/CacheServiceTest.java index 939d05ec4..bc1f4866f 100644 --- a/server/src/test/java/org/eclipse/openvsx/cache/CacheServiceTest.java +++ b/server/src/test/java/org/eclipse/openvsx/cache/CacheServiceTest.java @@ -336,7 +336,7 @@ private void setRequest() { private UserData insertAdmin() { var admin = new UserData(); admin.setLoginName("super_user"); - admin.setRole("admin"); + admin.setRole(UserData.Role.ADMIN); entityManager.persist(admin); return admin; diff --git a/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java b/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java index 2271f1044..9fb6288a2 100644 --- a/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java +++ b/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java @@ -9,11 +9,36 @@ ********************************************************************************/ package org.eclipse.openvsx.repositories; -import jakarta.persistence.EntityManager; -import jakarta.transaction.Transactional; -import org.eclipse.openvsx.entities.*; +import static java.util.stream.Collectors.toList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.lang.reflect.Modifier; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.stream.Stream; + +import org.eclipse.openvsx.entities.AdminScanDecision; +import org.eclipse.openvsx.entities.Customer; +import org.eclipse.openvsx.entities.DailyUsageStats; +import org.eclipse.openvsx.entities.Extension; +import org.eclipse.openvsx.entities.ExtensionScan; +import org.eclipse.openvsx.entities.ExtensionThreat; +import org.eclipse.openvsx.entities.ExtensionValidationFailure; +import org.eclipse.openvsx.entities.ExtensionVersion; +import org.eclipse.openvsx.entities.FileDecision; +import org.eclipse.openvsx.entities.Namespace; +import org.eclipse.openvsx.entities.PersonalAccessToken; +import org.eclipse.openvsx.entities.ScanCheckResult; +import org.eclipse.openvsx.entities.ScanStatus; +import org.eclipse.openvsx.entities.SignatureKeyPair; +import org.eclipse.openvsx.entities.Tier; +import org.eclipse.openvsx.entities.TierType; +import org.eclipse.openvsx.entities.UsageStats; +import org.eclipse.openvsx.entities.UserData; import org.eclipse.openvsx.json.QueryRequest; -import org.eclipse.openvsx.storage.*; import org.eclipse.openvsx.util.ExtensionId; import org.junit.jupiter.api.Test; import org.mockito.Mockito; @@ -21,18 +46,11 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.test.context.ActiveProfiles; -import java.lang.reflect.Modifier; -import java.time.Duration; -import java.time.LocalDateTime; -import java.util.Collections; -import java.util.List; -import java.util.stream.Stream; - -import static java.util.stream.Collectors.toList; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; +import jakarta.persistence.EntityManager; +import jakarta.transaction.Transactional; /** * Run the DB queries and assert no DB error, just to ensure that the queries @@ -190,6 +208,7 @@ void testExecuteQueries() { () -> repositories.findTargetPlatformVersions("version", "extensionName", "namespaceName"), () -> repositories.findUserByLoginName("provider", "loginName"), () -> repositories.findUsersByLoginNameStartingWith("loginNameStart", 1), + () -> repositories.searchUsers("search", "role", Pageable.ofSize(25)), () -> repositories.findVersion("version", "targetPlatform", extension), () -> repositories.findVersion("version", "targetPlatform", "extensionName", "namespace"), () -> repositories.findVersions(extension), diff --git a/webui/CHANGELOG.md b/webui/CHANGELOG.md index 603ec06ed..967cd7065 100644 --- a/webui/CHANGELOG.md +++ b/webui/CHANGELOG.md @@ -8,6 +8,7 @@ This change log covers only the frontend library (webui) of Open VSX. - Add support to retry failed scanner jobs in the admin dashboard ([#1832](https://github.com/eclipse-openvsx/openvsx/pull/1832)) - Display non-terminal scanner jobs in the scan card ([#1836](https://github.com/eclipse-openvsx/openvsx/pull/1836)) +- Support searching users and managing their roles in the admin dashboard ([#TBD](https://github.com/eclipse-openvsx/openvsx/pull/1847)) ## [v0.20.3] (08/05/2026) diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts index a01e3191f..7b99e98d3 100644 --- a/webui/src/extension-registry-service.ts +++ b/webui/src/extension-registry-service.ts @@ -16,6 +16,7 @@ import { FilesResponse, FileDecisionCountsJson, ScanDecisionRequest, ScanDecisionResponse, FileDecisionRequest, FileDecisionResponse, FileDecisionDeleteRequest, FileDecisionDeleteResponse, Tier, TierList, Customer, CustomerList, UsageStatsList, LogPageableList, CustomerMembershipList, RateLimitToken, + AdminUsersResult, } from './extension-registry-types'; import { createAbsoluteURL, addQuery } from './utils'; import { sendRequest, ErrorResponse } from './server-request'; @@ -223,7 +224,7 @@ export class ExtensionRegistryService { abortController, method: 'POST', credentials: true, - endpoint: createAbsoluteURL([this.serverUrl, 'admin', 'extension', extension.namespace, extension.name, 'review', user.provider || 'github', user.loginName, 'delete']), + endpoint: createAbsoluteURL([this.serverUrl, 'admin', 'extension', extension.namespace, extension.name, 'review', user.provider!, user.loginName, 'delete']), headers }); } @@ -507,6 +508,13 @@ export interface AdminService { deleteNamespace(abortController: AbortController, namespace: { name: string }): Promise> changeNamespace(abortController: AbortController, req: {oldNamespace: string, newNamespace: string, removeOldNamespace: boolean, mergeIfNewNamespaceAlreadyExists: boolean}): Promise> getPublisherInfo(abortController: AbortController, provider: string, login: string): Promise> + getUsers(abortController: AbortController, params?: { + search?: string; + role?: string; + page?: number; + size?: number; + }): Promise> + updateUserRole(abortController: AbortController, provider: string, login: string, role: 'admin' | 'privileged' | 'none'): Promise> revokePublisherContributions(abortController: AbortController, provider: string, login: string): Promise> revokeAccessTokens(abortController: AbortController, provider: string, login: string): Promise> getAllScans(abortController: AbortController, params?: { size?: number; offset?: number; status?: string | string[]; publisher?: string; namespace?: string; name?: string; validationType?: string[]; threatScannerName?: string[]; dateStartedFrom?: string; dateStartedTo?: string; enforcement?: 'enforced' | 'notEnforced' | 'all' }): Promise> @@ -647,6 +655,54 @@ export class AdminServiceImpl implements AdminService { }); } + async getUsers(abortController: AbortController, params?: { + search?: string; + role?: string; + page?: number; + size?: number; + }): Promise> { + const query: { key: string, value: string | number }[] = []; + if (params) { + if (params.search) { + query.push({ key: 'search', value: params.search }); + } + if (params.role) { + query.push({ key: 'role', value: params.role }); + } + if (params.page !== undefined) { + query.push({ key: 'page', value: params.page }); + } + if (params.size !== undefined) { + query.push({ key: 'size', value: params.size }); + } + } + + return sendRequest({ + abortController, + credentials: true, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'users'], query) + }); + } + + async updateUserRole(abortController: AbortController, provider: string, login: string, role: 'admin' | 'privileged' | 'none'): Promise> { + const csrfResponse = await this.registry.getCsrfToken(abortController); + const headers: Record = {}; + if (!isError(csrfResponse)) { + const csrfToken = csrfResponse as CsrfTokenJson; + headers[csrfToken.header] = csrfToken.value; + } + + const query = [{ key: 'role', value: role }]; + + return sendRequest({ + abortController, + method: 'POST', + credentials: true, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'user', provider, login, 'role'], query), + headers + }); + } + async revokePublisherContributions(abortController: AbortController, provider: string, login: string): Promise> { const csrfResponse = await this.registry.getCsrfToken(abortController); const headers: Record = {}; diff --git a/webui/src/extension-registry-types.ts b/webui/src/extension-registry-types.ts index cc2d5fc4f..dc8b406ee 100644 --- a/webui/src/extension-registry-types.ts +++ b/webui/src/extension-registry-types.ts @@ -171,6 +171,22 @@ export interface UserData { additionalLogins?: UserData[]; } +export interface AdminUser { + user: UserData; + namespaces: NamespaceDetails[]; + customers: Customer[]; +} + +export interface AdminUsersResult { + content: AdminUser[]; + page: { + size: number; + number: number; + totalElements: number; + totalPages: number; + }; +} + export function isEqualUser(u1: UserData, u2: UserData): boolean { return u1.loginName === u2.loginName; } diff --git a/webui/src/pages/admin-dashboard/admin-dashboard.tsx b/webui/src/pages/admin-dashboard/admin-dashboard.tsx index 9344b20cd..e5bc780ad 100644 --- a/webui/src/pages/admin-dashboard/admin-dashboard.tsx +++ b/webui/src/pages/admin-dashboard/admin-dashboard.tsx @@ -50,7 +50,7 @@ const UsageStatsView = lazy(() => import('./usage-stats/usage-stats').then(m => const navConfig: NavEntry[] = [ { path: AdminDashboardRoutes.NAMESPACE_ADMIN, name: 'Namespaces', icon: , description: 'Manage user roles and create new namespaces' }, { path: AdminDashboardRoutes.EXTENSION_ADMIN, name: 'Extensions', icon: , description: 'Search for extensions and remove certain versions' }, - { path: AdminDashboardRoutes.PUBLISHER_ADMIN, name: 'Publisher', icon: , description: 'Search for publishers and revoke their contributions' }, + { path: AdminDashboardRoutes.PUBLISHER_ADMIN, name: 'Publisher', icon: , description: 'Search for publishers, update roles, and revoke their contributions' }, { path: AdminDashboardRoutes.SCANS_ADMIN, name: 'Scans', icon: , description: 'View security scan results and manage quarantined extensions' }, { name: 'Rate Limiting', @@ -126,6 +126,7 @@ export const AdminDashboard: FunctionComponent = props => { } /> + } /> } /> } /> } /> diff --git a/webui/src/pages/admin-dashboard/namespace-admin.tsx b/webui/src/pages/admin-dashboard/namespace-admin.tsx index d6facaaee..e92d53a0c 100644 --- a/webui/src/pages/admin-dashboard/namespace-admin.tsx +++ b/webui/src/pages/admin-dashboard/namespace-admin.tsx @@ -10,15 +10,19 @@ import { FunctionComponent, useState, useContext, useEffect, useRef, ReactNode } from 'react'; import { Typography, Box } from '@mui/material'; +import { useParams, useNavigate } from 'react-router-dom'; import { NamespaceDetail, NamespaceDetailConfigContext } from '../user/user-settings-namespace-detail'; import { ButtonWithProgress } from '../../components/button-with-progress'; import { Namespace, isError } from '../../extension-registry-types'; import { MainContext } from '../../context'; import { StyledInput } from './namespace-input'; import { SearchListContainer } from './search-list-container'; +import { AdminDashboardRoutes } from './admin-dashboard-routes'; export const NamespaceAdmin: FunctionComponent = props => { const { pageSettings, service, user, handleError } = useContext(MainContext); + const { namespace: nsParam } = useParams<{ namespace?: string }>(); + const navigate = useNavigate(); const [loading, setLoading] = useState(false); const [currentNamespace, setCurrentNamespace] = useState(); @@ -39,6 +43,7 @@ export const NamespaceAdmin: FunctionComponent = props => { if (!namespaceName) { setCurrentNamespace(undefined); setNotFound(''); + navigate(AdminDashboardRoutes.NAMESPACE_ADMIN, { replace: true }); return; } try { @@ -50,6 +55,7 @@ export const NamespaceAdmin: FunctionComponent = props => { setCurrentNamespace(namespace); setNotFound(''); setLoading(false); + navigate(`${AdminDashboardRoutes.NAMESPACE_ADMIN}/${encodeURIComponent(namespaceName)}`, { replace: true }); } catch (err) { if (err && err.status === 404) { setNotFound(namespaceName); @@ -61,11 +67,19 @@ export const NamespaceAdmin: FunctionComponent = props => { } }; - const [inputValue, setInputValue] = useState(''); + const [inputValue, setInputValue] = useState(nsParam ?? ''); const onChangeInput = (name: string) => { setInputValue(name); }; + // Auto-fetch namespace from path param + useEffect(() => { + if (nsParam) { + setInputValue(nsParam); + void fetchNamespace(nsParam); + } + }, [nsParam]); + const [creating, setCreating] = useState(false); const onCreate = async () => { try { @@ -109,7 +123,7 @@ export const NamespaceAdmin: FunctionComponent = props => { return ] + [] } listContainer={listContainer} loading={loading} diff --git a/webui/src/pages/admin-dashboard/publisher-admin.tsx b/webui/src/pages/admin-dashboard/publisher-admin.tsx index 449b5bcff..6440d9b14 100644 --- a/webui/src/pages/admin-dashboard/publisher-admin.tsx +++ b/webui/src/pages/admin-dashboard/publisher-admin.tsx @@ -8,97 +8,611 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import { FunctionComponent, useState, useContext, createContext, useEffect, useRef, useCallback, ReactNode } from 'react'; -import { Typography, Box } from '@mui/material'; -import { useParams, useNavigate } from 'react-router-dom'; -import { PublisherInfo } from '../../extension-registry-types'; +import { FunctionComponent, createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Alert, + Avatar, + Badge, + Box, + Chip, + CircularProgress, + Divider, + FormControl, + IconButton, + InputLabel, + MenuItem, + Paper, + Popover, + Select, + Stack, + Tooltip, + Typography, +} from '@mui/material'; +import InfiniteScroll from 'react-infinite-scroller'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import FilterListIcon from '@mui/icons-material/FilterList'; +import GitHubIcon from '@mui/icons-material/GitHub'; +import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; +import PersonIcon from '@mui/icons-material/Person'; +import FolderSharedIcon from '@mui/icons-material/FolderShared'; +import BusinessIcon from '@mui/icons-material/Business'; +import { useParams, useNavigate, Link as RouterLink } from 'react-router-dom'; +import { ButtonWithProgress } from '../../components/button-with-progress'; +import { AdminUser as UserRelationships, isError, PublisherInfo } from '../../extension-registry-types'; +import { ErrorResponse } from '../../server-request'; +import { ExtensionRegistryService } from '../../extension-registry-service'; import { MainContext } from '../../context'; +import { PublisherDetails } from './publisher-details'; import { StyledInput } from './namespace-input'; import { SearchListContainer } from './search-list-container'; -import { PublisherDetails } from './publisher-details'; +import { handleError as formatError } from '../../utils'; import { AdminDashboardRoutes } from './admin-dashboard-routes'; // eslint-disable-next-line react-refresh/only-export-components export const UpdateContext = createContext({ handleUpdate: () => { } }); -export const PublisherAdmin: FunctionComponent = () => { - const { publisher: publisherParam } = useParams<{ publisher: string }>(); - const navigate = useNavigate(); - const { pageSettings, service, user, handleError } = useContext(MainContext); +const DEBOUNCE_MS = 300; +const PAGE_SIZE = 25; + +const ROLE_FILTER_OPTIONS = [ + { value: '', label: 'Any role' }, + { value: 'admin', label: 'Admin' }, + { value: 'privileged', label: 'Privileged' }, + { value: 'none', label: 'No role' }, +]; + +type Role = 'admin' | 'privileged' | 'none'; + +const ROLE_EDITOR_OPTIONS: { value: Role; label: string }[] = [ + { value: 'none', label: 'No role' }, + { value: 'admin', label: 'Admin' }, + { value: 'privileged', label: 'Privileged' }, +]; + +type ReportError = (err: Error | Partial) => void; + +const getUserKey = (entry: UserRelationships) => `${entry.user.provider}/${entry.user.loginName}`; + +const providerIcon = (provider: string | undefined) => + provider === 'github' ? : ; + +const roleIcon = (role: string | undefined) => + role ? : ; + +function findScrollableAncestor(el: HTMLElement | null): HTMLElement | null { + while (el) { + const { overflowY } = getComputedStyle(el); + if (overflowY === 'auto' || overflowY === 'scroll') return el; + el = el.parentElement; + } + return null; +} + +function usePublisherDetail( + entry: UserRelationships | undefined, + service: ExtensionRegistryService, + reportError: ReportError, + onRoleMutate: (provider: string, loginName: string, newRole: Role) => string | undefined, + onRoleRollback: (provider: string, loginName: string, previousRole: string | undefined) => void, +) { + const [publisherInfo, setPublisherInfo] = useState(); + const [publisherLoading, setPublisherLoading] = useState(false); + const [publisherError, setPublisherError] = useState(null); + const [roleDraft, setRoleDraft] = useState('none'); + const [savingRole, setSavingRole] = useState(false); + + const entryProvider = entry?.user.provider; + const entryLoginName = entry?.user.loginName ?? ''; + const entryKey = entry ? `${entryProvider}/${entryLoginName}` : ''; - const abortController = useRef(new AbortController()); useEffect(() => { - return () => { - abortController.current.abort(); + if (!entry) { + setPublisherInfo(undefined); + setPublisherError(null); + setRoleDraft('none'); + return; + } + + setRoleDraft((entry.user.role as 'admin' | 'privileged') ?? 'none'); + const abortController = new AbortController(); + + const load = async () => { + try { + setPublisherLoading(true); + setPublisherError(null); + const info = await service.admin.getPublisherInfo(abortController, entryProvider!, entryLoginName); + setPublisherInfo(info); + } catch (err) { + if (!abortController.signal.aborted) { + reportError(err as Error | Partial); + setPublisherError(formatError(err as Error | Partial)); + setPublisherInfo(undefined); + } + } finally { + if (!abortController.signal.aborted) setPublisherLoading(false); + } }; - }, []); - const [loading, setLoading] = useState(false); + void load(); + return () => abortController.abort(); + }, [entryKey, service, reportError]); - const [publisher, setPublisher] = useState(); - const [notFound, setNotFound] = useState(''); + const handleRoleSave = useCallback(async () => { + if (!entry || roleDraft === (entry.user.role ?? 'none')) return; + + const provider = entry.user.provider; + if (!provider) return; + const loginName = entry.user.loginName; + const previousRole = onRoleMutate(provider, loginName, roleDraft); - const fetchPublisher = useCallback(async (publisherName: string) => { try { - setLoading(true); - if (publisherName === '') { - setNotFound(''); - setPublisher(undefined); - } else { - const pub = await service.admin.getPublisherInfo(abortController.current, 'github', publisherName); - setNotFound(''); - setPublisher(pub); - } - setLoading(false); + setSavingRole(true); + const result = await service.admin.updateUserRole(new AbortController(), provider, loginName, roleDraft); + if (isError(result)) throw result; } catch (err) { - if (err?.status === 404) { - setNotFound(publisherName); - setPublisher(undefined); - } else { - handleError(err); - } - setLoading(false); + onRoleRollback(provider, loginName, previousRole); + setRoleDraft((previousRole as 'admin' | 'privileged') ?? 'none'); + reportError(err as Error | Partial); + setPublisherError(formatError(err as Error | Partial)); + } finally { + setSavingRole(false); } - }, [service, handleError]); + }, [entry, roleDraft, service, reportError, onRoleMutate, onRoleRollback]); + + return { + publisherInfo, + publisherLoading, + publisherError, + roleDraft, + setRoleDraft, + saveRole: useCallback(() => { + void handleRoleSave(); +}, [handleRoleSave]), + savingRole, + clearError: useCallback(() => setPublisherError(null), []), + }; +} + +const FiltersPopover: FunctionComponent<{ + anchorEl: HTMLElement | null; + onClose: () => void; + roleFilter: string; + onRoleChange: (role: string) => void; +}> = ({ anchorEl, onClose, roleFilter, onRoleChange }) => ( + + + Filters + + Role + + + + +); + +const RoleEditor: FunctionComponent<{ + roleDraft: Role; + onChange: (role: Role) => void; + onSave: () => void; + saving: boolean; +}> = ({ roleDraft, onChange, onSave, saving }) => ( + + Role + + + + + + Save + + + +); + +interface UserListItemProps { + entry: UserRelationships; + index: number; + expanded: boolean; + onToggle: (userKey: string, loginName: string, isExpanded: boolean) => void; + onLoadingChange: (loading: boolean) => void; + onRoleMutate: (provider: string, loginName: string, newRole: Role) => string | undefined; + onRoleRollback: (provider: string, loginName: string, previousRole: string | undefined) => void; +} + +const UserListItem: FunctionComponent = ({ + entry, index, expanded, onToggle, onLoadingChange, onRoleMutate, onRoleRollback, +}) => { + const { user } = entry; + const userKey = getUserKey(entry); + const { service, handleError: reportError, user: currentUser } = useContext(MainContext); + const [pendingExpand, setPendingExpand] = useState(false); + const isCurrentUser = currentUser?.loginName === user.loginName + && currentUser?.provider === user.provider; + + const { + publisherInfo, publisherLoading, publisherError, + roleDraft, setRoleDraft, saveRole, savingRole, + clearError, + } = usePublisherDetail( + expanded || pendingExpand ? entry : undefined, + service, reportError, onRoleMutate, onRoleRollback, + ); useEffect(() => { - if (publisherParam) { - fetchPublisher(publisherParam); + onLoadingChange(pendingExpand); +}, [pendingExpand, onLoadingChange]); + + useEffect(() => { + if (pendingExpand && !publisherLoading && (publisherInfo || publisherError)) { + setPendingExpand(false); + onToggle(userKey, user.loginName, true); } - }, [publisherParam, fetchPublisher]); + }, [pendingExpand, publisherLoading, publisherInfo, publisherError, onToggle, userKey, user.loginName]); - const handleSubmit = (inputValue: string) => { - if (inputValue) { - navigate(`${AdminDashboardRoutes.PUBLISHER_ADMIN}/${inputValue}`); + const handleChange = useCallback((_: unknown, isExpanded: boolean) => { + if (isExpanded) { + if (publisherInfo) { + onToggle(userKey, user.loginName, true); + } else { + setPendingExpand(true); + } } else { - navigate(AdminDashboardRoutes.PUBLISHER_ADMIN); + setPendingExpand(false); + onToggle(userKey, user.loginName, false); } - }; + }, [publisherInfo, onToggle, userKey, user.loginName]); + + return ( + + } sx={{ px: 0 }}> + + + + + {user.loginName} + {isCurrentUser && ( + + )} + + + {user.fullName || '—'} + + + + + + + + + + {publisherError && {publisherError}} + {publisherInfo && ( + <> + + + {entry.namespaces.length > 0 && ( + + + + Namespaces + + + {entry.namespaces.map(ns => ( + + ))} + + + )} + {entry.customers.length > 0 && ( + + + + Customers + + + {entry.customers.map(cust => { + const tier = cust?.tier; + return ( + + + {tier && ( + + + + )} + {cust && !tier && ( + no tier + )} + + ); + })} + + + )} + + + + + )} + + + ); +}; - const handleUpdate = () => { - if (publisherParam) { - fetchPublisher(publisherParam); +export const PublisherAdmin: FunctionComponent = () => { + const { publisher: publisherParam } = useParams<{ publisher?: string }>(); + const { service, handleError: reportError } = useContext(MainContext); + const navigate = useNavigate(); + + const initialSearch = publisherParam ?? ''; + const [users, setUsers] = useState([]); + const [totalSize, setTotalSize] = useState(0); + const [usersLoading, setUsersLoading] = useState(true); + const [usersError, setUsersError] = useState(null); + const [searchText, setSearchText] = useState(initialSearch); + const [debouncedSearch, setDebouncedSearch] = useState(initialSearch); + const [roleFilter, setRoleFilter] = useState(''); + const [hasMore, setHasMore] = useState(false); + const [fetchCounter, setFetchCounter] = useState(0); + const [expandedUserKey, setExpandedUserKey] = useState(); + const [filterOpen, setFilterOpen] = useState(false); + const [detailLoading, setDetailLoading] = useState(false); + + const debounceTimer = useRef(); + const filterButtonRef = useRef(null); + const initialParamHandled = useRef(false); + const pageRef = useRef(0); + const enableLoadMore = useRef(false); + const scrollParentRef = useRef(null); + const containerRef = useRef(null); + + const getScrollParent = useCallback(() => { + scrollParentRef.current ??= findScrollableAncestor(containerRef.current?.parentElement ?? null); + return scrollParentRef.current; + }, []); + + const onSearchChange = useCallback((value: string) => { + setSearchText(value); + clearTimeout(debounceTimer.current); + debounceTimer.current = globalThis.setTimeout(() => setDebouncedSearch(value), DEBOUNCE_MS); + }, []); + + const onSearchSubmit = useCallback((value: string) => { + setSearchText(value); + clearTimeout(debounceTimer.current); + setDebouncedSearch(value); + }, []); + + const handleAccordionToggle = useCallback((userKey: string, loginName: string, isExpanded: boolean) => { + if (isExpanded) { + setExpandedUserKey(userKey); + navigate(`${AdminDashboardRoutes.PUBLISHER_ADMIN}/${encodeURIComponent(loginName)}`, { replace: true }); + } else { + setExpandedUserKey(undefined); + navigate(AdminDashboardRoutes.PUBLISHER_ADMIN, { replace: true }); } - }; + }, [navigate]); - let listContainer: ReactNode = ''; - if (publisher && pageSettings && user) { - listContainer = - - ; - } else if (notFound) { - listContainer = - - Publisher {notFound} not found. - - ; - } + const handleDetailLoadingChange = useCallback((loading: boolean) => setDetailLoading(loading), []); + + const refresh = useCallback(() => setFetchCounter(c => c + 1), []); + const updateContextValue = useCallback(() => ({ handleUpdate: refresh }), [refresh]); + + const handleRoleMutate = useCallback((provider: string, loginName: string, newRole: Role): string | undefined => { + let previousRole: string | undefined; + setUsers(prev => prev.map(u => { + if (u.user.provider === provider && u.user.loginName === loginName) { + previousRole = u.user.role; + return { ...u, user: { ...u.user, role: newRole === 'none' ? undefined : newRole } }; + } + return u; + })); + return previousRole; + }, []); + + const handleRoleRollback = useCallback((provider: string, loginName: string, previousRole: string | undefined) => { + setUsers(prev => prev.map(u => + u.user.provider === provider && u.user.loginName === loginName + ? { ...u, user: { ...u.user, role: previousRole } } + : u + )); + }, []); - return {}} />] + useEffect(() => () => clearTimeout(debounceTimer.current), []); + + useEffect(() => { + const abortController = new AbortController(); + enableLoadMore.current = false; + + const fetchUsers = async () => { + try { + setUsersLoading(true); + setUsersError(null); + setHasMore(false); + pageRef.current = 0; + const data = await service.admin.getUsers(abortController, { + search: debouncedSearch || undefined, + role: roleFilter || undefined, + size: PAGE_SIZE, + page: 0, + }); + const content = data.content ?? []; + const total = data.page?.totalElements ?? 0; + setUsers(content); + setTotalSize(total); + setHasMore(content.length < total); + enableLoadMore.current = true; + } catch (err) { + if (!abortController.signal.aborted) { + reportError(err as Error | Partial); + setUsersError(formatError(err as Error | Partial)); + } + } finally { + if (!abortController.signal.aborted) setUsersLoading(false); + } + }; + + void fetchUsers(); + return () => abortController.abort(); + }, [debouncedSearch, roleFilter, fetchCounter, service, reportError]); + + const loadMore = useCallback(async () => { + if (!enableLoadMore.current) return; + enableLoadMore.current = false; + const nextPage = ++pageRef.current; + + try { + const data = await service.admin.getUsers(new AbortController(), { + search: debouncedSearch || undefined, + role: roleFilter || undefined, + size: PAGE_SIZE, + page: nextPage, + }); + const content = data.content ?? []; + const total = data.page?.totalElements ?? 0; + setUsers(prev => { + const updated = [...prev, ...content]; + setHasMore(updated.length < total && content.length > 0); + return updated; + }); + setTotalSize(total); + } catch (err) { + reportError(err as Error | Partial); + setUsersError(formatError(err as Error | Partial)); + } finally { + enableLoadMore.current = true; } - listContainer={listContainer} - loading={loading} - />; -}; \ No newline at end of file + }, [debouncedSearch, roleFilter, service, reportError]); + + // Collapse when expanded user leaves the result set + useEffect(() => { + if (expandedUserKey && !users.some(e => getUserKey(e) === expandedUserKey)) { + setExpandedUserKey(undefined); + navigate(AdminDashboardRoutes.PUBLISHER_ADMIN, { replace: true }); + } + }, [expandedUserKey, users, navigate]); + + // Auto-expand user from URL param on initial load + useEffect(() => { + if (!publisherParam || initialParamHandled.current) return; + const matched = users.find(e => e.user.loginName === publisherParam); + if (matched) { + setExpandedUserKey(getUserKey(matched)); + initialParamHandled.current = true; + } + }, [publisherParam, users]); + + return ( + + + + ]} + listContainer={null} + loading={usersLoading || detailLoading} + /> + + + setFilterOpen(true)} size='small' title='Filters'> + + + + + {totalSize > 0 && ( + + {totalSize} user{totalSize === 1 ? '' : 's'} found + + )} + + + setFilterOpen(false)} + roleFilter={roleFilter} + onRoleChange={setRoleFilter} + /> + + {usersError && setUsersError(null)}>{usersError}} + + {!usersLoading && !usersError && users.length === 0 && ( + + No users matched the current filters. + + )} + + {!usersLoading && users.length > 0 && ( + + + + } + > + + {users.map((entry, index) => ( + + ))} + + + )} + + + + ); +}; diff --git a/webui/src/pages/admin-dashboard/publisher-details.tsx b/webui/src/pages/admin-dashboard/publisher-details.tsx index 136c94502..deb50b202 100644 --- a/webui/src/pages/admin-dashboard/publisher-details.tsx +++ b/webui/src/pages/admin-dashboard/publisher-details.tsx @@ -26,7 +26,7 @@ export const PublisherDetails: FunctionComponent = props Access Tokens - {props.publisherInfo.activeAccessTokenNum} active access token{props.publisherInfo.activeAccessTokenNum !== 1 ? 's' : ''}. + {props.publisherInfo.activeAccessTokenNum} active access token{props.publisherInfo.activeAccessTokenNum === 1 ? '' : 's'}. {props.publisherInfo.activeAccessTokenNum > 0 && } diff --git a/webui/src/pages/user/add-namespace-member-dialog.tsx b/webui/src/pages/user/add-namespace-member-dialog.tsx index 56d8397c6..95290f9c2 100644 --- a/webui/src/pages/user/add-namespace-member-dialog.tsx +++ b/webui/src/pages/user/add-namespace-member-dialog.tsx @@ -46,7 +46,7 @@ export const AddMemberDialog: FunctionComponent = props => props.onClose(); } catch (err) { props.setLoadingState(false); - handleError(err); + handleError(err as Error); } }; diff --git a/webui/src/pages/user/user-namespace-extension-list-item.tsx b/webui/src/pages/user/user-namespace-extension-list-item.tsx index 889f1c5e0..29db263a8 100644 --- a/webui/src/pages/user/user-namespace-extension-list-item.tsx +++ b/webui/src/pages/user/user-namespace-extension-list-item.tsx @@ -126,8 +126,8 @@ export const UserNamespaceExtensionListItem: FunctionComponent