diff --git a/server/src/main/java/org/eclipse/openvsx/UserAPI.java b/server/src/main/java/org/eclipse/openvsx/UserAPI.java index 1d3ff54c2..50e5c9bf5 100644 --- a/server/src/main/java/org/eclipse/openvsx/UserAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/UserAPI.java @@ -12,19 +12,13 @@ import jakarta.servlet.http.HttpServletRequest; import org.eclipse.openvsx.accesstoken.AccessTokenService; import org.eclipse.openvsx.eclipse.EclipseService; -import org.eclipse.openvsx.entities.ExtensionVersion; -import org.eclipse.openvsx.entities.NamespaceMembership; -import org.eclipse.openvsx.entities.ScanStatus; -import org.eclipse.openvsx.entities.UserData; +import org.eclipse.openvsx.entities.*; import org.eclipse.openvsx.json.*; import org.eclipse.openvsx.repositories.ExtensionScanRepository; import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.security.CodedAuthException; import org.eclipse.openvsx.storage.StorageUtilService; -import org.eclipse.openvsx.util.ErrorResultException; -import org.eclipse.openvsx.util.NamingUtil; -import org.eclipse.openvsx.util.NotFoundException; -import org.eclipse.openvsx.util.UrlUtil; +import org.eclipse.openvsx.util.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.CacheControl; @@ -484,6 +478,50 @@ public ResponseEntity setNamespaceMember(@PathVariable String namesp } } + @GetMapping( + path = "/user/customers", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public List getOwnCustomers() { + var user = users.findLoggedInUser(); + if (user == null) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } + + return repositories.findCustomerMemberships(user).map(membership -> membership.getCustomer().toUserJson() ).toList(); + } + + @GetMapping( + path = "/user/customers/{name}/usage", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity getOwnUsageStats(@PathVariable String name, @RequestParam(required = false) String date) { + try { + var user = users.findLoggedInUser(); + if (user == null) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } + + var customer = repositories.findCustomer(name); + if (customer == null) { + return ResponseEntity.notFound().build(); + } + + var membership = repositories.findCustomerMembership(user, customer); + if (membership == null) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } + + var localDateTime = date != null ? TimeUtil.fromUTCString(date) : TimeUtil.getCurrentUTC(); + var stats = repositories.findUsageStatsByCustomerAndDate(customer, localDateTime); + var result = new UsageStatsListJson(stats.stream().map(UsageStats::toJson).toList()); + return ResponseEntity.ok(result); + } catch (Exception exc) { + logger.error("failed retrieving usage stats", exc); + return ResponseEntity.internalServerError().build(); + } + } + @GetMapping( path = "/user/search/{name}", produces = MediaType.APPLICATION_JSON_VALUE diff --git a/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java b/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java index 319256e47..f0d8c22a1 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java @@ -12,13 +12,12 @@ *****************************************************************************/ package org.eclipse.openvsx.admin; -import org.eclipse.openvsx.entities.Customer; -import org.eclipse.openvsx.entities.Tier; -import org.eclipse.openvsx.entities.TierType; -import org.eclipse.openvsx.entities.UsageStats; +import org.eclipse.openvsx.entities.*; import org.eclipse.openvsx.json.*; +import org.eclipse.openvsx.ratelimit.CustomerService; import org.eclipse.openvsx.ratelimit.cache.RateLimitCacheService; import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.util.ErrorResultException; import org.eclipse.openvsx.util.LogService; import org.eclipse.openvsx.util.TimeUtil; import org.slf4j.Logger; @@ -27,7 +26,6 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.time.LocalDateTime; import java.util.Optional; @@ -39,17 +37,20 @@ public class RateLimitAPI { private final RepositoryService repositories; private final AdminService admins; private final LogService logs; + private final CustomerService customerService; private RateLimitCacheService rateLimitCacheService; public RateLimitAPI( RepositoryService repositories, AdminService admins, LogService logs, + CustomerService customerService, Optional rateLimitCacheService ) { this.repositories = repositories; this.admins = admins; this.logs = logs; + this.customerService = customerService; rateLimitCacheService.ifPresent(service -> this.rateLimitCacheService = service); } @@ -222,6 +223,26 @@ public ResponseEntity getCustomers() { } } + @GetMapping( + path = "/customers/{name}", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity getCustomer(@PathVariable String name) { + try { + admins.checkAdminUser(); + + var customer = repositories.findCustomer(name); + if (customer == null) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.ok(customer.toJson()); + } catch (Exception exc) { + logger.error("failed retrieving customer {}", name, exc); + return ResponseEntity.internalServerError().build(); + } + } + @PostMapping( path = "/customers/create", consumes = MediaType.APPLICATION_JSON_VALUE, @@ -306,6 +327,75 @@ public ResponseEntity updateCustomer(@PathVariable String name, @R } } + @GetMapping( + path = "/customers/{name}/members", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity getCustomerMembers(@PathVariable String name) { + try { + admins.checkAdminUser(); + + var customer = repositories.findCustomer(name); + if (customer == null) { + return ResponseEntity.notFound().build(); + } + + var memberships = repositories.findCustomerMemberships(customer); + var membershipList = new CustomerMembershipListJson(); + membershipList.setCustomerMemberships(memberships.stream().map(CustomerMembership::toJson).toList()); + return ResponseEntity.ok(membershipList); + } catch (Exception exc) { + logger.error("failed retrieving customer members {}", name, exc); + return ResponseEntity.internalServerError().build(); + } + } + + @PostMapping( + path = "/customers/{name}/add-member", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity addCustomerMember( + @PathVariable String name, + @RequestParam("user") String userName, + @RequestParam(required = false) String provider + ) { + try { + var admin = admins.checkAdminUser(); + + var result = customerService.addCustomerMember(name, userName, provider); + logs.logAction(admin, result); + return ResponseEntity.ok(result); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(); + } catch (Exception exc) { + logger.error("failed adding user {} to customer {}", userName, name, exc); + return ResponseEntity.internalServerError().build(); + } + } + + @PostMapping( + path = "/customers/{name}/remove-member", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity removeCustomerMember( + @PathVariable String name, + @RequestParam("user") String userName, + @RequestParam(required = false) String provider + ) { + try { + var admin = admins.checkAdminUser(); + + var result = customerService.removeCustomerMember(name, userName, provider); + logs.logAction(admin, result); + return ResponseEntity.ok(result); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(); + } catch (Exception exc) { + logger.error("failed removing user {} from customer {}", userName, name, exc); + return ResponseEntity.internalServerError().build(); + } + } + @DeleteMapping( path = "/customers/{name}", produces = MediaType.APPLICATION_JSON_VALUE 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 ce43dfe76..88367b409 100644 --- a/server/src/main/java/org/eclipse/openvsx/entities/Customer.java +++ b/server/src/main/java/org/eclipse/openvsx/entities/Customer.java @@ -96,6 +96,14 @@ public CustomerJson toJson() { return json; } + public CustomerJson toUserJson() { + var json = new CustomerJson(); + json.setName(name); + json.setTier(tier.toJson()); + json.setState(""); + return json; + } + public Customer updateFromJson(CustomerJson json) { setName(json.getName()); setTier(Tier.fromJson(json.getTier())); diff --git a/server/src/main/java/org/eclipse/openvsx/entities/CustomerMembership.java b/server/src/main/java/org/eclipse/openvsx/entities/CustomerMembership.java new file mode 100644 index 000000000..ff475d30d --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/entities/CustomerMembership.java @@ -0,0 +1,87 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ +package org.eclipse.openvsx.entities; + +import jakarta.persistence.*; +import org.eclipse.openvsx.json.CustomerMembershipJson; +import org.eclipse.openvsx.json.NamespaceMembershipJson; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Objects; + +@Entity +public class CustomerMembership implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(generator = "customerMembershipSeq") + @SequenceGenerator(name = "customerMembershipSeq", sequenceName = "customer_membership_seq", allocationSize = 1) + private long id; + + @ManyToOne + @JoinColumn(name = "customer") + private Customer customer; + + @ManyToOne + @JoinColumn(name = "user_data") + private UserData user; + + public CustomerMembershipJson toJson() { + return new CustomerMembershipJson( + this.customer.getName(), + this.user.toUserJson() + ); + } + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public UserData getUser() { + return user; + } + + public void setUser(UserData user) { + this.user = user; + } + + public Customer getCustomer() { + return customer; + } + + public void setCustomer(Customer customer) { + this.customer = customer; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + CustomerMembership that = (CustomerMembership) o; + return id == that.id + && Objects.equals(customer, that.customer) + && Objects.equals(user, that.user); + } + + @Override + public int hashCode() { + return Objects.hash(id, customer, user); + } +} \ No newline at end of file diff --git a/server/src/main/java/org/eclipse/openvsx/json/CustomerMembershipJson.java b/server/src/main/java/org/eclipse/openvsx/json/CustomerMembershipJson.java new file mode 100644 index 000000000..0f039ec96 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/CustomerMembershipJson.java @@ -0,0 +1,22 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 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; + +@JsonInclude(Include.NON_NULL) +public record CustomerMembershipJson( + String customer, + UserJson user +) {} diff --git a/server/src/main/java/org/eclipse/openvsx/json/CustomerMembershipListJson.java b/server/src/main/java/org/eclipse/openvsx/json/CustomerMembershipListJson.java new file mode 100644 index 000000000..28f7f1bb3 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/CustomerMembershipListJson.java @@ -0,0 +1,46 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 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 io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +@Schema( + name = "CustomerMembershipList", + description = "Metadata of a customer member list" +) +@JsonInclude(Include.NON_NULL) +public class CustomerMembershipListJson extends ResultJson { + + public static CustomerMembershipListJson error(String message) { + var result = new CustomerMembershipListJson(); + result.setError(message); + return result; + } + + @Schema(description = "List of memberships") + @NotNull + private List customerMemberships; + + public List getCustomerMemberships() { + return customerMemberships; + } + + public void setCustomerMemberships(List customerMemberships) { + this.customerMemberships = customerMemberships; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/CustomerService.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/CustomerService.java index f3b3d242e..d71bd8091 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/CustomerService.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/CustomerService.java @@ -15,11 +15,18 @@ import inet.ipaddr.IPAddressString; import inet.ipaddr.ipv4.IPv4AddressAssociativeTrie; import jakarta.annotation.PostConstruct; +import jakarta.persistence.EntityManager; +import jakarta.transaction.Transactional; import org.eclipse.openvsx.entities.Customer; +import org.eclipse.openvsx.entities.CustomerMembership; +import org.eclipse.openvsx.entities.NamespaceMembership; +import org.eclipse.openvsx.entities.UserData; +import org.eclipse.openvsx.json.ResultJson; import org.eclipse.openvsx.ratelimit.cache.ConfigurationChanged; import org.eclipse.openvsx.ratelimit.cache.RateLimitCacheService; import org.eclipse.openvsx.ratelimit.config.RateLimitConfig; import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.util.ErrorResultException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; @@ -30,15 +37,16 @@ import java.util.Optional; @Service -@ConditionalOnBean(RateLimitConfig.class) public class CustomerService { private final Logger logger = LoggerFactory.getLogger(CustomerService.class); + private final EntityManager entityManager; private final RepositoryService repositories; private IPv4AddressAssociativeTrie customersByIPAddress; - public CustomerService(RepositoryService repositories) { + public CustomerService(EntityManager entityManager, RepositoryService repositories) { + this.entityManager = entityManager; this.repositories = repositories; } @@ -84,4 +92,47 @@ private IPv4AddressAssociativeTrie rebuildIPAddressCache() { } return trie; } + + @Transactional(rollbackOn = ErrorResultException.class) + public ResultJson addCustomerMember(String customerName, String userName, String provider) throws ErrorResultException { + var customer = repositories.findCustomer(customerName); + if (customer == null) { + throw new ErrorResultException("Customer not found: " + customerName); + } + var user = repositories.findUserByLoginName(provider, userName); + if (user == null) { + throw new ErrorResultException("User not found: " + (provider + "/" + userName)); + } + + var membership = repositories.findCustomerMembership(user, customer); + if (membership != null) { + throw new ErrorResultException("User " + user.getLoginName() + " is already member of customer " + customer.getName() + "."); + } + + membership = new CustomerMembership(); + membership.setCustomer(customer); + membership.setUser(user); + entityManager.persist(membership); + return ResultJson.success("Added " + user.getLoginName() + " as member of customer " + customer.getName() + "."); + } + + @Transactional(rollbackOn = ErrorResultException.class) + public ResultJson removeCustomerMember(String customerName, String userName, String provider) throws ErrorResultException { + var customer = repositories.findCustomer(customerName); + if (customer == null) { + throw new ErrorResultException("Customer not found: " + customerName); + } + var user = repositories.findUserByLoginName(provider, userName); + if (user == null) { + throw new ErrorResultException("User not found: " + (provider + "/" + userName)); + } + + var membership = repositories.findCustomerMembership(user, customer); + if (membership == null) { + throw new ErrorResultException("User " + user.getLoginName() + " is not a member of customer " + customer.getName() + "."); + } + + entityManager.remove(membership); + return ResultJson.success("Removed " + user.getLoginName() + " as member of customer " + customer.getName() + "."); + } } diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/CustomerMembershipRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/CustomerMembershipRepository.java new file mode 100644 index 000000000..d8f892e24 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/repositories/CustomerMembershipRepository.java @@ -0,0 +1,25 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ +package org.eclipse.openvsx.repositories; + +import org.eclipse.openvsx.entities.*; +import org.springframework.data.repository.Repository; +import org.springframework.data.util.Streamable; + +public interface CustomerMembershipRepository extends Repository { + Streamable findByCustomer(Customer customer); + + CustomerMembership findByUserAndCustomer(UserData user, Customer customer); + + Streamable findByUserOrderByCustomerName(UserData user); +} 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 cb13c80e9..2a5a56357 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java @@ -76,6 +76,7 @@ public class RepositoryService { private final ScanCheckResultRepository scanCheckResultRepo; private final TierRepository tierRepo; private final CustomerRepository customerRepo; + private final CustomerMembershipRepository customerMembershipRepo; private final UsageStatsRepository usageStatsRepository; public RepositoryService( @@ -110,6 +111,7 @@ public RepositoryService( ScanCheckResultRepository scanCheckResultRepo, TierRepository tierRepo, CustomerRepository customerRepo, + CustomerMembershipRepository customerMembershipRepo, UsageStatsRepository usageStatsRepository ) { this.namespaceRepo = namespaceRepo; @@ -143,6 +145,7 @@ public RepositoryService( this.scanCheckResultRepo = scanCheckResultRepo; this.tierRepo = tierRepo; this.customerRepo = customerRepo; + this.customerMembershipRepo = customerMembershipRepo; this.usageStatsRepository = usageStatsRepository; } @@ -1276,6 +1279,18 @@ public void deleteCustomer(Customer customer) { customerRepo.delete(customer); } + public Streamable findCustomerMemberships(Customer customer) { + return customerMembershipRepo.findByCustomer(customer); + } + + public CustomerMembership findCustomerMembership(UserData user, Customer customer) { + return customerMembershipRepo.findByUserAndCustomer(user, customer); + } + + public Streamable findCustomerMemberships(UserData user) { + return customerMembershipRepo.findByUserOrderByCustomerName(user); + } + public List findUsageStatsByCustomerAndDate(Customer customer, LocalDateTime date) { var startTime = date.truncatedTo(ChronoUnit.DAYS).minusMinutes(5); var endTime = date.truncatedTo(ChronoUnit.DAYS).plusDays(1); diff --git a/server/src/main/java/org/eclipse/openvsx/web/DocumentationConfig.java b/server/src/main/java/org/eclipse/openvsx/web/DocumentationConfig.java index 2b42379d4..a7ea52bc7 100644 --- a/server/src/main/java/org/eclipse/openvsx/web/DocumentationConfig.java +++ b/server/src/main/java/org/eclipse/openvsx/web/DocumentationConfig.java @@ -75,28 +75,42 @@ public OpenApiCustomizer sortSchemasAlphabetically() { @Bean public OpenApiCustomizer addRateLimitResponse() { - var retryAfterHeader = new Header() - .description("Number of seconds to wait after receiving a 429 response") + var limitLimitHeader = new Header() + .description("Number of requests that can be made in a given amount of time") .schema(new Schema<>().type("integer").format("int32")); var limitRemainingHeader = new Header() - .description("Remaining number of requests left") + .description("Remaining number of requests left in the current time window") + .schema(new Schema<>().type("integer").format("int32")); + var limitResetHeader = new Header() + .description("Number of seconds until the rate limit tokens will be fully filled to its maximum") + .schema(new Schema<>().type("integer").format("int32")); + var retryAfterHeader = new Header() + .description("Number of seconds to wait after receiving a 429 response") .schema(new Schema<>().type("integer").format("int32")); var response = new ApiResponse() .description("A client has sent too many requests in a given amount of time") .headers(Map.of( - "X-Rate-Limit-Retry-After-Seconds", retryAfterHeader, - "X-Rate-Limit-Remaining", limitRemainingHeader + "X-RateLimit-Limit", limitLimitHeader, + "X-RateLimit-Remaining", limitRemainingHeader, + "X-RateLimit-Reset", limitResetHeader, + "Retry-After", retryAfterHeader )); return openApi -> openApi.getPaths() .forEach((path, item) -> item.readOperations() .forEach(operation -> { var responses = operation.getResponses(); - if(responses == null) { + if (responses == null) { responses = new ApiResponses(); } + // add default rate limit headers present in all responses + responses.forEach((status, r) -> { + r.addHeaderObject("X-RateLimit-Limit", limitLimitHeader); + r.addHeaderObject("X-RateLimit-Remaining", limitRemainingHeader); + }); + responses.addApiResponse("429", response); operation.setResponses(responses); }) diff --git a/server/src/main/resources/db/migration/V1_67__Rate_Limit_P2.sql b/server/src/main/resources/db/migration/V1_67__Rate_Limit_P2.sql new file mode 100644 index 000000000..99bdd4242 --- /dev/null +++ b/server/src/main/resources/db/migration/V1_67__Rate_Limit_P2.sql @@ -0,0 +1,23 @@ +-- database changes for Rate Limit phase 2 + +-- customer_membership table + +CREATE SEQUENCE IF NOT EXISTS customer_membership_seq START WITH 1 INCREMENT BY 1; + +CREATE TABLE IF NOT EXISTS public.customer_membership +( + id BIGINT NOT NULL PRIMARY KEY DEFAULT nextval('customer_membership_seq'), + customer bigint, + user_data bigint, + + -- foreign keys + + CONSTRAINT customer_membership_customer_fk FOREIGN KEY (customer) + REFERENCES public.customer(id) ON DELETE CASCADE, + + CONSTRAINT customer_membership_user_data_fk FOREIGN KEY (user_data) + REFERENCES public.user_data(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS customer_membership_namespace_idx ON public.customer_membership(customer); +CREATE INDEX IF NOT EXISTS customer_membership_user_data_idx ON public.customer_membership(user_data); diff --git a/server/src/test/java/org/eclipse/openvsx/ratelimit/CustomerServiceTest.java b/server/src/test/java/org/eclipse/openvsx/ratelimit/CustomerServiceTest.java index e78e3406b..143ef317d 100644 --- a/server/src/test/java/org/eclipse/openvsx/ratelimit/CustomerServiceTest.java +++ b/server/src/test/java/org/eclipse/openvsx/ratelimit/CustomerServiceTest.java @@ -12,6 +12,7 @@ *****************************************************************************/ package org.eclipse.openvsx.ratelimit; +import jakarta.persistence.EntityManager; import org.eclipse.openvsx.entities.Customer; import org.eclipse.openvsx.repositories.RepositoryService; import org.junit.jupiter.api.Test; @@ -30,6 +31,9 @@ @ExtendWith(SpringExtension.class) public class CustomerServiceTest { + @MockitoBean + EntityManager entityManager; + @MockitoBean RepositoryService repositories; @@ -59,8 +63,8 @@ private Customer mockCustomer() { @TestConfiguration static class TestConfig { @Bean - public CustomerService customerService(RepositoryService repositoryService) { - return new CustomerService(repositoryService); + public CustomerService customerService(EntityManager entityManager, RepositoryService repositoryService) { + return new CustomerService(entityManager, repositoryService); } } } 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 761e83453..81089fd32 100644 --- a/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java +++ b/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java @@ -380,6 +380,9 @@ void testExecuteQueries() { () -> repositories.findCustomersByTier(tier), () -> repositories.countCustomersByTier(tier), () -> repositories.findAllCustomers(), + () -> repositories.findCustomerMemberships(customer), + () -> repositories.findCustomerMemberships(userData), + () -> repositories.findCustomerMembership(userData, customer), () -> repositories.saveUsageStats(usageStats), () -> repositories.findUsageStatsByCustomerAndDate(customer, NOW), () -> repositories.deleteTier(tier), diff --git a/webui/configs/base.tsconfig.json b/webui/configs/base.tsconfig.json index 022c715c5..b7a4b89dc 100644 --- a/webui/configs/base.tsconfig.json +++ b/webui/configs/base.tsconfig.json @@ -1,11 +1,11 @@ { "compilerOptions": { - "target": "es6", - "module": "es6", + "target": "es2020", + "module": "es2020", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true, "lib": [ - "es6", "es2020.string", "dom" + "es2020", "dom" ], "typeRoots": [ "node_modules/@types", "typings" diff --git a/webui/eslint.config.mjs b/webui/eslint.config.mjs index 39577ff64..eb275b580 100644 --- a/webui/eslint.config.mjs +++ b/webui/eslint.config.mjs @@ -6,6 +6,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import js from "@eslint/js"; import { FlatCompat } from "@eslint/eslintrc"; +import { reactRefresh } from "eslint-plugin-react-refresh"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -20,7 +21,9 @@ export default [...compat.extends( "plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended", "plugin:react/recommended", -), { +), + reactRefresh.configs.vite(), +{ files: ["**/*.ts", "**/*.tsx"], plugins: { "@typescript-eslint": typescriptEslint, diff --git a/webui/package.json b/webui/package.json index ff7cbc72f..d07e7772c 100644 --- a/webui/package.json +++ b/webui/package.json @@ -52,10 +52,10 @@ "@mui/x-date-pickers": "^6.20", "clipboard-copy": "^4.0.1", "clsx": "^1.2.1", - "date-fns": "^2.30.0", "dompurify": "^3.0.4", "fetch-retry": "^5.0.6", "lodash": "^4.17.21", + "luxon": "^3.7.2", "markdown-it": "^14.1.0", "markdown-it-anchor": "^9.2.0", "prop-types": "^15.8.1", @@ -84,6 +84,7 @@ "@types/dompurify": "^3.0.2", "@types/express": "^4.17.21", "@types/lodash": "^4.14.195", + "@types/luxon": "^3.7.1", "@types/markdown-it": "^14.1.0", "@types/mocha": "^10.0.0", "@types/node": "^22.0.0", @@ -101,6 +102,7 @@ "chai": "^4.3.0", "eslint": "^9.39.0", "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-refresh": "^0.5.2", "express": "^4.21.0", "express-rate-limit": "^7.4.0", "mocha": "^11.7.0", diff --git a/webui/src/components/rate-limiting/customer/general-details.tsx b/webui/src/components/rate-limiting/customer/general-details.tsx new file mode 100644 index 000000000..7baa661a1 --- /dev/null +++ b/webui/src/components/rate-limiting/customer/general-details.tsx @@ -0,0 +1,106 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { FC, type ReactNode } from 'react'; +import { + Box, + Typography, + Paper, + type PaperProps, + Chip, + Stack, + Divider, + Grid, +} from '@mui/material'; +import type { Customer } from '../../../extension-registry-types'; + +const sectionPaperProps: PaperProps = { elevation: 1, sx: { p: 3, mb: 3 } }; + +export interface GeneralDetailsProps { + customer: Customer; + headerAction?: ReactNode; +} + +export const GeneralDetails: FC = ({ customer, headerAction }) => { + const tier = customer.tier; + return ( + + + General Information + {headerAction && {headerAction}} + + + + + Name + {customer.name} + + + State + + + + + {tier ? ( + <> + + Tier + + + + + + Tier Type + {tier.tierType} + + + Capacity + {tier.capacity} requests / {tier.duration}s + + + Refill Strategy + {tier.refillStrategy} + + {tier.description && ( + + Tier Description + {tier.description} + + )} + + ) : ( + + Tier + No tier assigned + + )} + + CIDR Blocks + {customer.cidrBlocks.length > 0 ? ( + + {customer.cidrBlocks.map((cidr) => ( + + ))} + + ) : ( + None configured + )} + + + + ); +}; diff --git a/webui/src/components/rate-limiting/customer/index.ts b/webui/src/components/rate-limiting/customer/index.ts new file mode 100644 index 000000000..882d9eddd --- /dev/null +++ b/webui/src/components/rate-limiting/customer/index.ts @@ -0,0 +1,18 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +export { GeneralDetails } from './general-details'; +export type { GeneralDetailsProps } from './general-details'; + +export { UsageStats } from './usage-stats'; +export type { UsageStatsProps } from './usage-stats'; diff --git a/webui/src/components/rate-limiting/customer/usage-stats.tsx b/webui/src/components/rate-limiting/customer/usage-stats.tsx new file mode 100644 index 000000000..71359ccd8 --- /dev/null +++ b/webui/src/components/rate-limiting/customer/usage-stats.tsx @@ -0,0 +1,50 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { FC } from 'react'; +import { + Typography, + Paper, + type PaperProps, + Divider, +} from '@mui/material'; +import type { Customer, UsageStats as UsageStatsType } from '../../../extension-registry-types'; +import type { DateTime } from 'luxon'; +import { UsageStatsChart } from '../usage-stats/usage-stats-chart'; + +const sectionPaperProps: PaperProps = { elevation: 1, sx: { p: 3, mb: 3 } }; + +export interface UsageStatsProps { + usageStats: readonly UsageStatsType[]; + customer: Customer; + startDate: DateTime; + onStartDateChange: (date: DateTime) => void; + compact?: boolean; +} + +export const UsageStats: FC = ({ usageStats, customer, startDate, onStartDateChange, compact }) => ( + + + Usage Statistics + + + + +); diff --git a/webui/src/pages/admin-dashboard/usage-stats/usage-stats-chart.tsx b/webui/src/components/rate-limiting/usage-stats/usage-stats-chart.tsx similarity index 71% rename from webui/src/pages/admin-dashboard/usage-stats/usage-stats-chart.tsx rename to webui/src/components/rate-limiting/usage-stats/usage-stats-chart.tsx index 576e0baef..735d97d98 100644 --- a/webui/src/pages/admin-dashboard/usage-stats/usage-stats-chart.tsx +++ b/webui/src/components/rate-limiting/usage-stats/usage-stats-chart.tsx @@ -17,14 +17,16 @@ import { Paper, Typography, Alert, - Stack + Stack, + IconButton } from "@mui/material"; import { BarPlot } from "@mui/x-charts/BarChart"; import { DatePicker } from "@mui/x-date-pickers/DatePicker"; import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; -import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns"; +import { AdapterLuxon } from "@mui/x-date-pickers/AdapterLuxon"; +import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; import type { Customer, UsageStats } from "../../../extension-registry-types"; -import { addDays, format, startOfDay } from "date-fns"; import { ChartsReferenceLine, ChartsTooltip, @@ -32,22 +34,27 @@ import { ChartsYAxis, ResponsiveChartContainer } from "@mui/x-charts"; +import { DateTime } from "luxon"; interface UsageStatsChartProps { usageStats: readonly UsageStats[]; customer: Customer | null; - startDate: Date; - onStartDateChange: (date: Date) => void; + startDate: DateTime; + onStartDateChange: (date: DateTime) => void; + embedded?: boolean; + compact?: boolean; } export const UsageStatsChart: FC = ({ usageStats, customer, startDate, - onStartDateChange + onStartDateChange, + embedded = false, + compact = false }) => { - const dayStart = startOfDay(startDate).getTime() / 1000; - const dayEnd = startOfDay(addDays(startDate, 1)).getTime() / 1000; + const dayStart = startDate.startOf('day').toMillis() / 1000; + const dayEnd = startDate.endOf('day').toMillis() / 1000; // we have 5min steps const step = 5 * 60; @@ -68,7 +75,9 @@ export const UsageStatsChart: FC = ({ for (const stat of usageStats) { const idx = (stat.windowStart - dayStart) / step; - arr[idx].count = stat.count; + if (idx >= 0 && idx < arr.length) { + arr[idx].count = stat.count; + } } return arr; }, [usageStats] @@ -89,9 +98,11 @@ export const UsageStatsChart: FC = ({ [usageStats] ); + const Wrapper: typeof Box = embedded ? Box : Paper; + return ( - - + + Filters @@ -100,16 +111,23 @@ export const UsageStatsChart: FC = ({ label='Start Date' value={startDate} onChange={onStartDateChange} - slotProps={{ textField: { size: 'small' } }} + timezone='UTC' + slotProps={{ textField: { size: 'small' }, actionBar: { actions: ['today'] } }} /> + onStartDateChange(startDate.minus({ days: 1 }))}> + + + onStartDateChange(startDate.plus({ days: 1 }))}> + + - + {usageStats.length === 0 ? No usage data available for this customer. : <> - + = ({ color: 'lightgray', }]} - height={400} - margin={{ top: 10 }} + height={compact ? 300 : 400} + margin={{ top: 30 }} xAxis={[ { id: 'date', - data: data.map((value) => new Date(value.windowStart * 1000)), + data: data.map((value) => value.windowStart * 1000), + valueFormatter: (value) => DateTime.fromMillis(value).toLocaleString(DateTime.TIME_24_SIMPLE), scaleType: 'band', - valueFormatter: (value) => format(new Date(value), 'HH:mm'), }, ]} yAxis={[ @@ -133,7 +151,7 @@ export const UsageStatsChart: FC = ({ id: 'requests', scaleType: 'linear', min: 0, - max: Math.max(tierCapacity, maxDataValue) + 10 + max: Math.max(tierCapacity, maxDataValue) + 30 }, ]} > @@ -153,14 +171,16 @@ export const UsageStatsChart: FC = ({ } { - return new Date(value).getMinutes() === 0; + tickInterval={(value) => { + const d = new Date(value); + return d.getMinutes() === 0 && (!compact || d.getHours() % 3 === 0); }} - tickLabelInterval={(value, index) => { - return new Date(value).getMinutes() === 0; + tickLabelInterval={(value) => { + const d = new Date(value); + return d.getMinutes() === 0 && (!compact || d.getHours() % 3 === 0); }} tickLabelStyle={{ fontSize: 10, @@ -174,7 +194,7 @@ export const UsageStatsChart: FC = ({ /> - + @@ -186,4 +206,4 @@ export const UsageStatsChart: FC = ({ } ); -}; \ No newline at end of file +}; diff --git a/webui/src/pages/admin-dashboard/usage-stats/usage-stats-utils.ts b/webui/src/components/rate-limiting/usage-stats/usage-stats-utils.ts similarity index 88% rename from webui/src/pages/admin-dashboard/usage-stats/usage-stats-utils.ts rename to webui/src/components/rate-limiting/usage-stats/usage-stats-utils.ts index c36de3302..57146cddb 100644 --- a/webui/src/pages/admin-dashboard/usage-stats/usage-stats-utils.ts +++ b/webui/src/components/rate-limiting/usage-stats/usage-stats-utils.ts @@ -11,6 +11,8 @@ * SPDX-License-Identifier: EPL-2.0 *****************************************************************************/ +import { DateTime } from "luxon"; + export const getDefaultStartDate = () => { - return new Date(); -}; \ No newline at end of file + return DateTime.now().setZone("UTC"); +}; diff --git a/webui/src/components/rate-limiting/usage-stats/use-usage-stats.ts b/webui/src/components/rate-limiting/usage-stats/use-usage-stats.ts new file mode 100644 index 000000000..db6a7f41d --- /dev/null +++ b/webui/src/components/rate-limiting/usage-stats/use-usage-stats.ts @@ -0,0 +1,70 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { useContext, useState, useEffect, useRef, useCallback } from "react"; +import { MainContext } from "../../../context"; +import type { UsageStats } from "../../../extension-registry-types"; +import { handleError } from "../../../utils"; +import { getDefaultStartDate } from "./usage-stats-utils"; +import { DateTime } from "luxon"; + +export const useUsageStats = (customerName: string | undefined) => { + const abortController = useRef(new AbortController()); + const { service } = useContext(MainContext); + + const [usageStats, setUsageStats] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [internalStartDate, setInternalStartDate] = useState(getDefaultStartDate); + + const startDateRef = useRef(internalStartDate); + startDateRef.current = internalStartDate; + + const fetchUsageStats = useCallback(async (date: DateTime) => { + if (!customerName) { + setUsageStats([]); + setLoading(false); + return; + } + + try { + setLoading(true); + setError(null); + const data = await service.getUsageStats( + abortController.current, + customerName, + date.toJSDate() + ); + setUsageStats(data.stats); + } catch (err) { + setError(handleError(err as Error)); + } finally { + setLoading(false); + } + }, [service, customerName]); + + const setStartDate = useCallback((date: DateTime) => { + setInternalStartDate(date); + fetchUsageStats(date); + }, [fetchUsageStats]); + + useEffect(() => { + fetchUsageStats(startDateRef.current); + return () => { + abortController.current.abort(); + abortController.current = new AbortController(); + }; + }, [fetchUsageStats]); + + return { usageStats, loading, error, startDate: internalStartDate, setStartDate }; +}; diff --git a/webui/src/components/scan-admin/scan-card/scan-card-header.tsx b/webui/src/components/scan-admin/scan-card/scan-card-header.tsx index c03a7f0fc..2e22e0c5c 100644 --- a/webui/src/components/scan-admin/scan-card/scan-card-header.tsx +++ b/webui/src/components/scan-admin/scan-card/scan-card-header.tsx @@ -32,8 +32,8 @@ import { getStatusColorSx, } from './utils'; import { createRoute } from '../../../utils'; -import { AdminDashboardRoutes } from '../../../pages/admin-dashboard/admin-dashboard'; -import { ExtensionDetailRoutes } from '../../../pages/extension-detail/extension-detail'; +import { AdminDashboardRoutes } from '../../../pages/admin-dashboard/admin-routes'; +import { ExtensionDetailRoutes } from '../../../pages/extension-detail/extension-detail-routes'; interface ScanCardHeaderProps { scan: ScanResult; diff --git a/webui/src/components/sidepanel/drawer-header.tsx b/webui/src/components/sidepanel/drawer-header.tsx new file mode 100644 index 000000000..4114710b7 --- /dev/null +++ b/webui/src/components/sidepanel/drawer-header.tsx @@ -0,0 +1,23 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { styled } from '@mui/material/styles'; + +export const DrawerHeader = styled('div')(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + padding: theme.spacing(0, 1), + // necessary for content to be below app bar + ...theme.mixins.toolbar, + justifyContent: 'flex-end', +})); diff --git a/webui/src/components/sidepanel/navigation-item.tsx b/webui/src/components/sidepanel/navigation-item.tsx index 1d5805de0..8e08c083e 100644 --- a/webui/src/components/sidepanel/navigation-item.tsx +++ b/webui/src/components/sidepanel/navigation-item.tsx @@ -11,6 +11,7 @@ import { FunctionComponent, PropsWithChildren, ReactNode, useState } from 'react'; import { ListItemButton, ListItemText, Collapse, List, ListItemIcon } from '@mui/material'; import ExpandLess from '@mui/icons-material/ExpandLess'; +import ExpandMore from '@mui/icons-material/ExpandMore'; import { useNavigate } from 'react-router'; export const NavigationItem: FunctionComponent> = props => { @@ -21,7 +22,7 @@ export const NavigationItem: FunctionComponent{props.icon} } - {props.children && open && } + {props.children && (open ? : )} { props.children && diff --git a/webui/src/components/sidepanel/sidepanel.tsx b/webui/src/components/sidepanel/sidepanel.tsx index 342b8ab7a..a10c8374a 100644 --- a/webui/src/components/sidepanel/sidepanel.tsx +++ b/webui/src/components/sidepanel/sidepanel.tsx @@ -9,30 +9,42 @@ ********************************************************************************/ import { FunctionComponent, PropsWithChildren } from 'react'; -import { Drawer, List } from '@mui/material'; -import { Theme } from '@mui/material/styles'; +import { Divider, Drawer, IconButton, List } from '@mui/material'; +import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; +import { DrawerHeader } from './drawer-header'; + +export const Sidepanel: FunctionComponent> = props => { + const width = props.width; -export const Sidepanel: FunctionComponent = props => { return ( ({ + sx={{ + width: width, + flexShrink: 0, '& .MuiDrawer-paper': { - position: 'relative', - justifyContent: 'space-between', - transition: theme.transitions.create('width', { - easing: theme.transitions.easing.sharp, - duration: theme.transitions.duration.enteringScreen - }), - overflowX: { xs: 'hidden', sm: 'hidden', md: 'none', lg: 'none', xl: 'none' }, - width: { xs: theme.spacing(7) + 1, sm: theme.spacing(9) + 1, md: 240 }, - } - })} + width: width, + boxSizing: 'border-box', + }, + }} + variant='persistent' + anchor='left' + open={props.open} > + + + + + + {props.children} ); -}; \ No newline at end of file +}; + +interface SidepanelProps { + width: number; + open: boolean; + handleDrawerClose: () => void; +} diff --git a/webui/src/context/scan-admin/scan-context.tsx b/webui/src/context/scan-admin/scan-context.tsx index 3c3dd0e62..f205b8dc1 100644 --- a/webui/src/context/scan-admin/scan-context.tsx +++ b/webui/src/context/scan-admin/scan-context.tsx @@ -114,6 +114,7 @@ export const ScanProvider: FC = ({ children, service, handleE // Custom Hook // ============================================================================ +// eslint-disable-next-line react-refresh/only-export-components export const useScanContext = (): ScanContextValue => { const context = useContext(ScanContext); if (!context) { diff --git a/webui/src/default/default-app.tsx b/webui/src/default/default-app.tsx index 821b02d49..7dc04478e 100644 --- a/webui/src/default/default-app.tsx +++ b/webui/src/default/default-app.tsx @@ -48,7 +48,7 @@ async function getServerVersion(): Promise { } } -const App = () => { +export const App = () => { const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)'); const theme = useMemo( () => createDefaultTheme(prefersDarkMode ? 'dark' : 'light'), diff --git a/webui/src/default/menu-content.tsx b/webui/src/default/menu-content.tsx index a5ec8b00f..eabb569ec 100644 --- a/webui/src/default/menu-content.tsx +++ b/webui/src/default/menu-content.tsx @@ -19,17 +19,18 @@ import InfoIcon from '@mui/icons-material/Info'; import PublishIcon from '@mui/icons-material/Publish'; import AccountBoxIcon from '@mui/icons-material/AccountBox'; import { UserAvatar } from '../pages/user/avatar'; -import { UserSettingsRoutes } from '../pages/user/user-settings'; +import { UserSettingsRoutes } from '../pages/user/user-settings-routes'; import { styled, Theme } from '@mui/material/styles'; import { MainContext } from '../context'; import SettingsIcon from '@mui/icons-material/Settings'; import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; import LogoutIcon from '@mui/icons-material/Logout'; -import { AdminDashboardRoutes } from '../pages/admin-dashboard/admin-dashboard'; +import { AdminDashboardRoutes } from '../pages/admin-dashboard/admin-routes'; import { LogoutForm } from '../pages/user/logout'; import { LoginComponent } from './login'; //-------------------- Mobile View --------------------// +// eslint-disable-next-line react-refresh/only-export-components export const itemIcon = { mr: 1, width: '16px', @@ -161,6 +162,7 @@ export const MobileMenuContent: FunctionComponent = () => { //-------------------- Default View --------------------// +// eslint-disable-next-line react-refresh/only-export-components export const headerItem = ({ theme }: { theme: Theme }) => ({ margin: theme.spacing(2.5), color: theme.palette.text.primary, @@ -175,7 +177,9 @@ export const headerItem = ({ theme }: { theme: Theme }) => ({ } }); +// eslint-disable-next-line react-refresh/only-export-components export const MenuLink = styled(Link)(headerItem); +// eslint-disable-next-line react-refresh/only-export-components export const MenuRouteLink = styled(RouteLink)(headerItem); export const DefaultMenuContent: FunctionComponent = () => { diff --git a/webui/src/default/page-settings.tsx b/webui/src/default/page-settings.tsx index d83acbece..8bcc0a161 100644 --- a/webui/src/default/page-settings.tsx +++ b/webui/src/default/page-settings.tsx @@ -16,7 +16,7 @@ import { Link as RouteLink, Route, useParams } from 'react-router-dom'; import GitHubIcon from '@mui/icons-material/GitHub'; import { Extension, NamespaceDetails } from '../extension-registry-types'; import { PageSettings } from '../page-settings'; -import { ExtensionListRoutes } from '../pages/extension-list/extension-list-container'; +import { ExtensionListRoutes } from '../pages/extension-list/extension-list-routes'; import { DefaultMenuContent, MobileMenuContent } from './menu-content'; import OpenVSXLogo from './openvsx-registry-logo'; import About from './about'; diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts index db8ff77e5..aaf8a6d55 100644 --- a/webui/src/extension-registry-service.ts +++ b/webui/src/extension-registry-service.ts @@ -15,7 +15,7 @@ import { LoginProviders, ScanResultJson, ScanCounts, ScanResultsResponse, ScanFilterOptions, FilesResponse, FileDecisionCountsJson, ScanDecisionRequest, ScanDecisionResponse, FileDecisionRequest, FileDecisionResponse, FileDecisionDeleteRequest, FileDecisionDeleteResponse, - Tier, TierList, Customer, CustomerList, UsageStatsList, LogPageableList, + Tier, TierList, Customer, CustomerList, UsageStatsList, LogPageableList, CustomerMembershipList, } from './extension-registry-types'; import { createAbsoluteURL, addQuery } from './utils'; import { sendRequest, ErrorResponse } from './server-request'; @@ -28,7 +28,7 @@ export class ExtensionRegistryService { this.admin = new AdminConstructor(this); } - getLoginProviders(abortController: AbortController): Promise> { + async getLoginProviders(abortController: AbortController): Promise> { const endpoint = createAbsoluteURL([this.serverUrl, 'login-providers']); return sendRequest({ abortController, endpoint }); } @@ -49,7 +49,7 @@ export class ExtensionRegistryService { return createAbsoluteURL(arr); } - getNamespaceDetails(abortController: AbortController, name: string): Promise> { + async getNamespaceDetails(abortController: AbortController, name: string): Promise> { const endpoint = createAbsoluteURL([this.serverUrl, 'api', name, 'details']); return sendRequest({ abortController, endpoint }); } @@ -95,7 +95,7 @@ export class ExtensionRegistryService { }); } - search(abortController: AbortController, filter?: ExtensionFilter): Promise> { + async search(abortController: AbortController, filter?: ExtensionFilter): Promise> { const query: { key: string, value: string | number }[] = []; if (filter) { if (filter.query) @@ -115,11 +115,11 @@ export class ExtensionRegistryService { return sendRequest({ abortController, endpoint }); } - getExtensionDetail(abortController: AbortController, extensionUrl: UrlString): Promise> { + async getExtensionDetail(abortController: AbortController, extensionUrl: UrlString): Promise> { return sendRequest({ abortController, endpoint: extensionUrl }); } - getExtensionReadme(abortController: AbortController, extension: Extension): Promise { + async getExtensionReadme(abortController: AbortController, extension: Extension): Promise { return sendRequest({ abortController, endpoint: extension.files.readme, @@ -128,7 +128,7 @@ export class ExtensionRegistryService { }); } - getExtensionChangelog(abortController: AbortController, extension: Extension): Promise { + async getExtensionChangelog(abortController: AbortController, extension: Extension): Promise { return sendRequest({ abortController, endpoint: extension.files.changelog, @@ -137,7 +137,7 @@ export class ExtensionRegistryService { }); } - getExtensionIcon(abortController: AbortController, extension: Extension | SearchEntry): Promise { + async getExtensionIcon(abortController: AbortController, extension: Extension | SearchEntry): Promise { if (!extension.files.icon) { return Promise.resolve(undefined); } @@ -173,7 +173,7 @@ export class ExtensionRegistryService { ]; } - getExtensionReviews(abortController: AbortController, extension: Extension): Promise> { + async getExtensionReviews(abortController: AbortController, extension: Extension): Promise> { return sendRequest({ abortController, endpoint: extension.reviewsUrl }); } @@ -228,7 +228,7 @@ export class ExtensionRegistryService { }); } - getUser(abortController: AbortController): Promise> { + async getUser(abortController: AbortController): Promise> { return sendRequest({ abortController, endpoint: createAbsoluteURL([this.serverUrl, 'user']), @@ -236,7 +236,7 @@ export class ExtensionRegistryService { }); } - getUserAuthError(abortController: AbortController): Promise> { + async getUserAuthError(abortController: AbortController): Promise> { return sendRequest({ abortController, endpoint: createAbsoluteURL([this.serverUrl, 'user', 'auth-error']), @@ -244,7 +244,7 @@ export class ExtensionRegistryService { }); } - getUserByName(abortController: AbortController, name: string): Promise[]> { + async getUserByName(abortController: AbortController, name: string): Promise[]> { return sendRequest({ abortController, endpoint: createAbsoluteURL([this.serverUrl, 'user', 'search', name]), @@ -252,7 +252,7 @@ export class ExtensionRegistryService { }); } - getAccessTokens(abortController: AbortController, user: UserData): Promise[]> { + async getAccessTokens(abortController: AbortController, user: UserData): Promise[]> { return sendRequest({ abortController, credentials: true, @@ -310,7 +310,7 @@ export class ExtensionRegistryService { }))); } - getCsrfToken(abortController: AbortController): Promise> { + async getCsrfToken(abortController: AbortController): Promise> { return sendRequest({ abortController, credentials: true, @@ -318,7 +318,7 @@ export class ExtensionRegistryService { }); } - getNamespaces(abortController: AbortController): Promise[]> { + async getNamespaces(abortController: AbortController): Promise[]> { return sendRequest({ abortController, credentials: true, @@ -326,7 +326,26 @@ export class ExtensionRegistryService { }); } - getNamespaceMembers(abortController: AbortController, namespace: Namespace): Promise> { + async getCustomers(abortController: AbortController): Promise[]> { + return sendRequest({ + abortController, + credentials: true, + endpoint: createAbsoluteURL([this.serverUrl, 'user', 'customers']) + }); + } + + async getUsageStats(abortController: AbortController, customerName: string, date: Date): Promise> { + const query: { key: string, value: string | number }[] = []; + query.push({ key: 'date', value: date.toISOString() }); + + return sendRequest({ + abortController, + endpoint: createAbsoluteURL([this.serverUrl, 'user', 'customers', customerName, 'usage'], query), + credentials: true + }, false); + } + + async getNamespaceMembers(abortController: AbortController, namespace: Namespace): Promise> { return sendRequest({ abortController, credentials: true, @@ -371,7 +390,7 @@ export class ExtensionRegistryService { }); } - getStaticContent(abortController: AbortController, url: string): Promise { + async getStaticContent(abortController: AbortController, url: string): Promise { return sendRequest({ abortController, endpoint: url, @@ -505,9 +524,13 @@ export interface AdminService { updateTier(abortController: AbortController, name: string, tier: Tier): Promise>; deleteTier(abortController: AbortController, name: string): Promise>; getCustomers(abortController: AbortController): Promise>; + getCustomer(abortController: AbortController, name: string): Promise>; createCustomer(abortController: AbortController, customer: Customer): Promise>; updateCustomer(abortController: AbortController, name: string, customer: Customer): Promise>; deleteCustomer(abortController: AbortController, name: string): Promise>; + getCustomerMembers(abortController: AbortController, name: string): Promise>; + addCustomerMember(abortController: AbortController, name: string, user: UserData): Promise>; + removeCustomerMember(abortController: AbortController, name: string, user: UserData): Promise>; getUsageStats(abortController: AbortController, customerName: string, date: Date): Promise>; getLogs(abortController: AbortController, page?: number, size?: number, period?: string): Promise>; } @@ -520,7 +543,7 @@ export class AdminServiceImpl implements AdminService { constructor(readonly registry: ExtensionRegistryService) {} - getExtension(abortController: AbortController, namespace: string, extension: string): Promise> { + async getExtension(abortController: AbortController, namespace: string, extension: string): Promise> { return sendRequest({ abortController, credentials: true, @@ -548,7 +571,7 @@ export class AdminServiceImpl implements AdminService { }); } - getNamespace(abortController: AbortController, name: string): Promise> { + async getNamespace(abortController: AbortController, name: string): Promise> { return sendRequest({ abortController, credentials: true, @@ -634,7 +657,7 @@ export class AdminServiceImpl implements AdminService { }); } - 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'; adminDecision?: string[] }): Promise> { + async 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'; adminDecision?: string[] }): Promise> { const query: { key: string, value: string | number }[] = []; if (params) { if (params.size !== undefined) @@ -672,7 +695,7 @@ export class AdminServiceImpl implements AdminService { }); } - getScan(abortController: AbortController, scanId: string): Promise> { + async getScan(abortController: AbortController, scanId: string): Promise> { return sendRequest({ abortController, credentials: true, @@ -680,7 +703,7 @@ export class AdminServiceImpl implements AdminService { }); } - getScanCounts(abortController: AbortController, params?: { dateStartedFrom?: string; dateStartedTo?: string; enforcement?: 'enforced' | 'notEnforced' | 'all'; threatScannerName?: string[]; validationType?: string[] }): Promise> { + async getScanCounts(abortController: AbortController, params?: { dateStartedFrom?: string; dateStartedTo?: string; enforcement?: 'enforced' | 'notEnforced' | 'all'; threatScannerName?: string[]; validationType?: string[] }): Promise> { const query: { key: string, value: string | number }[] = []; if (params) { if (params.dateStartedFrom) @@ -704,7 +727,7 @@ export class AdminServiceImpl implements AdminService { }); } - getScanFilterOptions(abortController: AbortController): Promise> { + async getScanFilterOptions(abortController: AbortController): Promise> { return sendRequest({ abortController, credentials: true, @@ -712,7 +735,7 @@ export class AdminServiceImpl implements AdminService { }); } - getFiles(abortController: AbortController, params?: { size?: number; offset?: number; decision?: string; publisher?: string; namespace?: string; name?: string; dateDecidedFrom?: string; dateDecidedTo?: string; sortBy?: string; sortOrder?: 'asc' | 'desc' }): Promise> { + async getFiles(abortController: AbortController, params?: { size?: number; offset?: number; decision?: string; publisher?: string; namespace?: string; name?: string; dateDecidedFrom?: string; dateDecidedTo?: string; sortBy?: string; sortOrder?: 'asc' | 'desc' }): Promise> { const query: { key: string, value: string | number }[] = []; if (params) { if (params.size !== undefined) @@ -744,7 +767,7 @@ export class AdminServiceImpl implements AdminService { }); } - getFileCounts(abortController: AbortController, params?: { dateDecidedFrom?: string; dateDecidedTo?: string }): Promise> { + async getFileCounts(abortController: AbortController, params?: { dateDecidedFrom?: string; dateDecidedTo?: string }): Promise> { const query: { key: string, value: string | number }[] = []; if (params) { if (params.dateDecidedFrom) @@ -885,13 +908,21 @@ export class AdminServiceImpl implements AdminService { } async getCustomers(abortController: AbortController): Promise> { - return sendRequest({ + return await sendRequest({ abortController, endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'ratelimit', 'customers']), credentials: true }, false); } + async getCustomer(abortController: AbortController, name: string): Promise> { + return await sendRequest({ + abortController, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'ratelimit', 'customers', name]), + credentials: true + }, false); + } + async createCustomer(abortController: AbortController, customer: Customer): Promise> { const csrfResponse = await this.registry.getCsrfToken(abortController); const headers: Record = { @@ -948,6 +979,54 @@ export class AdminServiceImpl implements AdminService { }, false); } + async getCustomerMembers(abortController: AbortController, name: string): Promise> { + return sendRequest({ + abortController, + credentials: true, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'ratelimit', 'customers', name, "members"]), + }, false); + } + + async addCustomerMember(abortController: AbortController, name: string, user: UserData): 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: 'user', value: user.loginName }, + { key: 'provider', value: user.provider }, + ]; + return sendRequest({ + abortController, + headers, + method: 'POST', + credentials: true, + endpoint: addQuery(createAbsoluteURL([this.registry.serverUrl, 'admin', 'ratelimit', 'customers', name, "add-member"]), query), + }, false); + } + + async removeCustomerMember(abortController: AbortController, name: string, user: UserData): 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: 'user', value: user.loginName }, + { key: 'provider', value: user.provider }, + ]; + return sendRequest({ + abortController, + headers, + method: 'POST', + credentials: true, + endpoint: addQuery(createAbsoluteURL([this.registry.serverUrl, 'admin', 'ratelimit', 'customers', name, "remove-member"]), query), + }, false); + } + /** * Get usage stats for a customer within an optional date range. */ diff --git a/webui/src/extension-registry-types.ts b/webui/src/extension-registry-types.ts index 6bbdb2ae9..be2c9ba28 100644 --- a/webui/src/extension-registry-types.ts +++ b/webui/src/extension-registry-types.ts @@ -458,6 +458,15 @@ export interface CustomerList { customers: Customer[]; } +export interface CustomerMembership { + customer: string; + user: UserData; +} + +export interface CustomerMembershipList { + customerMemberships: CustomerMembership[]; +} + export interface UsageStats { windowStart: number; // epoch seconds in UTC duration: number; // in seconds diff --git a/webui/src/main.tsx b/webui/src/main.tsx index 96cca9f2a..ddf493074 100644 --- a/webui/src/main.tsx +++ b/webui/src/main.tsx @@ -11,7 +11,8 @@ import { FunctionComponent, ReactNode, useEffect, useState, useRef } from 'react'; import { CssBaseline } from '@mui/material'; import { Route, Routes } from 'react-router-dom'; -import { AdminDashboard, AdminDashboardRoutes } from './pages/admin-dashboard/admin-dashboard'; +import { AdminDashboard } from './pages/admin-dashboard/admin-dashboard'; +import { AdminDashboardRoutes } from './pages/admin-dashboard/admin-routes'; import { ErrorDialog } from './components/error-dialog'; import { handleError } from './utils'; import { ExtensionRegistryService } from './extension-registry-service'; diff --git a/webui/src/other-pages.tsx b/webui/src/other-pages.tsx index a08c7a9ae..e1f382669 100644 --- a/webui/src/other-pages.tsx +++ b/webui/src/other-pages.tsx @@ -1,3 +1,16 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + import { FunctionComponent, useContext, useEffect, useState } from 'react'; import { Routes, Route } from 'react-router-dom'; import { AppBar, Box, Toolbar } from '@mui/material'; @@ -5,10 +18,14 @@ import { styled, Theme } from '@mui/material/styles'; import { Banner } from './components/banner'; import { MainContext } from './context'; import { HeaderMenu } from './header-menu'; -import { ExtensionListContainer, ExtensionListRoutes } from './pages/extension-list/extension-list-container'; -import { UserSettings, UserSettingsRoutes } from './pages/user/user-settings'; -import { NamespaceDetail, NamespaceDetailRoutes } from './pages/namespace-detail/namespace-detail'; -import { ExtensionDetail, ExtensionDetailRoutes } from './pages/extension-detail/extension-detail'; +import { ExtensionListContainer } from './pages/extension-list/extension-list-container'; +import { ExtensionListRoutes } from "./pages/extension-list/extension-list-routes"; +import { UserSettings } from './pages/user/user-settings'; +import { UserSettingsRoutes } from './pages/user/user-settings-routes'; +import { NamespaceDetail } from './pages/namespace-detail/namespace-detail'; +import { NamespaceDetailRoutes } from './pages/namespace-detail/namespace-detail-routes'; +import { ExtensionDetail } from './pages/extension-detail/extension-detail'; +import { ExtensionDetailRoutes } from './pages/extension-detail/extension-detail-routes'; import { getCookieValueByKey, setCookie } from './utils'; import { UserData } from './extension-registry-types'; import { NotFound } from './not-found'; diff --git a/webui/src/pages/admin-dashboard/admin-dashboard.tsx b/webui/src/pages/admin-dashboard/admin-dashboard.tsx index 421f1002d..8fcbc67e5 100644 --- a/webui/src/pages/admin-dashboard/admin-dashboard.tsx +++ b/webui/src/pages/admin-dashboard/admin-dashboard.tsx @@ -9,45 +9,48 @@ ********************************************************************************/ import { FunctionComponent, ReactNode, useContext, useState } from 'react'; -import { Box, Container, CssBaseline, Typography, IconButton } from '@mui/material'; -import { createRoute } from '../../utils'; -import { Sidepanel } from '../../components/sidepanel/sidepanel'; -import { NavigationItem } from '../../components/sidepanel/navigation-item'; +import { + Box, + Container, + CssBaseline, + Typography, + IconButton, + Breadcrumbs, + LinkProps, + Link, + Toolbar +} from '@mui/material'; +import MuiAppBar, { AppBarProps as MuiAppBarProps } from '@mui/material/AppBar'; +import { styled } from "@mui/material/styles"; +import { Link as RouterLink, Route, Routes, useNavigate, useLocation } from 'react-router-dom'; +import AccountBoxIcon from '@mui/icons-material/AccountBox'; import AssignmentIndIcon from '@mui/icons-material/AssignmentInd'; +import BarChartIcon from '@mui/icons-material/BarChart'; import ExtensionSharpIcon from '@mui/icons-material/ExtensionSharp'; -import { Route, Routes, useNavigate, useLocation } from 'react-router-dom'; -import { NamespaceAdmin } from './namespace-admin'; -import { ExtensionAdmin } from './extension-admin'; -import { MainContext } from '../../context'; import HighlightOffIcon from '@mui/icons-material/HighlightOff'; -import { Welcome } from './welcome'; -import { PublisherAdmin } from './publisher-admin'; -import PersonIcon from '@mui/icons-material/Person'; +import HistoryIcon from '@mui/icons-material/History'; +import MenuIcon from '@mui/icons-material/Menu'; import PeopleIcon from '@mui/icons-material/People'; -import { ScanAdmin } from './scan-admin'; +import PersonIcon from '@mui/icons-material/Person'; import SecurityIcon from '@mui/icons-material/Security'; +import SpeedIcon from '@mui/icons-material/Speed'; import StarIcon from '@mui/icons-material/Star'; -import BarChartIcon from '@mui/icons-material/BarChart'; -import HistoryIcon from '@mui/icons-material/History'; -import { Tiers } from './tiers/tiers'; +import { CustomerDetails } from './customers/customer-details'; import { Customers } from './customers/customers'; -import { UsageStatsView } from './usage-stats/usage-stats'; -import { Logs } from './logs/logs'; +import { DrawerHeader } from '../../components/sidepanel/drawer-header'; +import { Sidepanel } from "../../components/sidepanel/sidepanel"; +import { ExtensionAdmin } from './extension-admin'; import { LoginComponent } from "../../default/login"; -import AccountBoxIcon from '@mui/icons-material/AccountBox'; - -export namespace AdminDashboardRoutes { - export const ROOT = 'admin-dashboard'; - export const MAIN = createRoute([ROOT]); - export const NAMESPACE_ADMIN = createRoute([ROOT, 'namespaces']); - export const EXTENSION_ADMIN = createRoute([ROOT, 'extensions']); - export const PUBLISHER_ADMIN = createRoute([ROOT, 'publisher']); - export const SCANS_ADMIN = createRoute([ROOT, 'scans']); - export const TIERS = createRoute([ROOT, 'tiers']); - export const CUSTOMERS = createRoute([ROOT, 'customers']); - export const USAGE_STATS = createRoute([ROOT, 'usage']); - export const LOGS = createRoute([ROOT, 'logs']); -} +import { Logs } from './logs/logs'; +import { MainContext } from '../../context'; +import { NamespaceAdmin } from './namespace-admin'; +import { NavigationItem } from '../../components/sidepanel/navigation-item'; +import { PublisherAdmin } from './publisher-admin'; +import { ScanAdmin } from './scan-admin'; +import { Tiers } from './tiers/tiers'; +import { UsageStatsView } from './usage-stats/usage-stats'; +import { Welcome } from './welcome'; +import { AdminDashboardRoutes } from "./admin-routes"; const Message: FunctionComponent<{message: string}> = ({ message }) => { return ( = ({ message }) => { ); }; +interface RouteEntry { + path: string; + name: string; + icon: ReactNode; +} + +interface NavGroup { + name: string; + icon: ReactNode; + children: RouteEntry[]; +} + +type NavEntry = RouteEntry | NavGroup; + +const isNavGroup = (entry: NavEntry): entry is NavGroup => 'children' in entry; + +const navConfig: NavEntry[] = [ + { path: AdminDashboardRoutes.NAMESPACE_ADMIN, name: 'Namespaces', icon: }, + { path: AdminDashboardRoutes.EXTENSION_ADMIN, name: 'Extensions', icon: }, + { path: AdminDashboardRoutes.PUBLISHER_ADMIN, name: 'Publisher', icon: }, + { path: AdminDashboardRoutes.SCANS_ADMIN, name: 'Scans', icon: }, + { + name: 'Rate Limiting', + icon: , + children: [ + { path: AdminDashboardRoutes.TIERS, name: 'Tiers', icon: }, + { path: AdminDashboardRoutes.CUSTOMERS, name: 'Customers', icon: }, + { path: AdminDashboardRoutes.USAGE_STATS, name: 'Usage Stats', icon: }, + ], + }, + { path: AdminDashboardRoutes.LOGS, name: 'Logs', icon: }, +]; + +// Flat name lookup for breadcrumbs +const routeNames: { [key: string]: string } = { + [AdminDashboardRoutes.MAIN]: 'Admin Dashboard', + ...navConfig.reduce<{ [key: string]: string }>((acc, entry) => { + if (isNavGroup(entry)) { + entry.children.forEach(child => { + acc[child.path] = child.name; + }); + } else { + acc[entry.path] = entry.name; + } + return acc; + }, {}), +}; + +const drawerWidth = 240; + +interface AppBarProps extends MuiAppBarProps { + open?: boolean; +} + +const AppBar = styled(MuiAppBar, { + shouldForwardProp: (prop) => prop !== 'open', +})(({ theme }) => ({ + transition: theme.transitions.create(['margin', 'width'], { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + variants: [ + { + props: ({ open }) => open, + style: { + width: `calc(100% - ${drawerWidth}px)`, + marginLeft: `${drawerWidth}px`, + transition: theme.transitions.create(['margin', 'width'], { + easing: theme.transitions.easing.easeOut, + duration: theme.transitions.duration.enteringScreen, + }), + }, + }, + ], +})); + +interface LinkRouterProps extends LinkProps { + to: string; + replace?: boolean; +} + +const LinkRouter = (props: LinkRouterProps) => ( + +); + +const BreadcrumbsComponent = () => { + const { pathname } = useLocation(); + + const pathnames = pathname.split("/").filter((segment) => segment); + + return ( + + + Home + + {pathnames.map((value, index) => { + const last = index === pathnames.length - 1; + const to = `/${pathnames.slice(0, index + 1).join("/")}`; + + return last ? ( + + {routeNames[to] ?? value} + + ) : ( + + {routeNames[to]} + + ); + })} + + ); +}; + +const Main = styled('main', { shouldForwardProp: (prop) => prop !== 'open' })<{ + open?: boolean; +}>(({ theme }) => ({ + flexGrow: 1, + padding: theme.spacing(3), + transition: theme.transitions.create('margin', { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + marginLeft: `-${drawerWidth}px`, + variants: [ + { + props: ({ open }) => open, + style: { + transition: theme.transitions.create('margin', { + easing: theme.transitions.easing.easeOut, + duration: theme.transitions.duration.enteringScreen, + }), + marginLeft: 0, + }, + }, + ], +})); + export const AdminDashboard: FunctionComponent = props => { const { user, loginProviders } = useContext(MainContext); + const [drawerOpen, setDrawerOpen] = useState(true); const navigate = useNavigate(); const toMainPage = () => navigate('/'); const [currentPage, setCurrentPage] = useState(useLocation().pathname); - const handleOpenRoute = (route: string) => setCurrentPage(route); + const handleOpenRoute = (route: string) => { + setCurrentPage(route); + }; let content: ReactNode = null; if (user?.role === 'admin') { - content = <> - - } route={AdminDashboardRoutes.NAMESPACE_ADMIN} /> - } route={AdminDashboardRoutes.EXTENSION_ADMIN} /> - } route={AdminDashboardRoutes.PUBLISHER_ADMIN} /> - } route={AdminDashboardRoutes.SCANS_ADMIN} /> - } route={AdminDashboardRoutes.TIERS} /> - } route={AdminDashboardRoutes.CUSTOMERS} /> - } route={AdminDashboardRoutes.USAGE_STATS} /> - } route={AdminDashboardRoutes.LOGS} /> - - - - - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - ; + content = + + + + + setDrawerOpen(true)} + edge='start' + sx={[{ mr: 2 }, drawerOpen && { display: 'none' }]} + > + + + + + + + + + + + setDrawerOpen(false)} > + {navConfig.map((entry) => { + if (isNavGroup(entry)) { + return ( + + {entry.children.map((child) => ( + + ))} + + ); + } + return ( + + ); + })} + +
+ + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + +
+
; } else if (user) { content = ; } else if (!props.userLoading && loginProviders) { diff --git a/webui/src/pages/admin-dashboard/admin-routes.ts b/webui/src/pages/admin-dashboard/admin-routes.ts new file mode 100644 index 000000000..579ac2f88 --- /dev/null +++ b/webui/src/pages/admin-dashboard/admin-routes.ts @@ -0,0 +1,27 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { createRoute } from '../../utils'; + +export namespace AdminDashboardRoutes { + export const ROOT = 'admin-dashboard'; + export const MAIN = createRoute([ROOT]); + export const NAMESPACE_ADMIN = createRoute([ROOT, 'namespaces']); + export const EXTENSION_ADMIN = createRoute([ROOT, 'extensions']); + export const PUBLISHER_ADMIN = createRoute([ROOT, 'publisher']); + export const SCANS_ADMIN = createRoute([ROOT, 'scans']); + export const TIERS = createRoute([ROOT, 'tiers']); + export const CUSTOMERS = createRoute([ROOT, 'customers']); + export const USAGE_STATS = createRoute([ROOT, 'usage']); + export const LOGS = createRoute([ROOT, 'logs']); +} diff --git a/webui/src/pages/admin-dashboard/components/data-grid-filter-operators.tsx b/webui/src/pages/admin-dashboard/components/data-grid-filter-operators.tsx index 4dd77bf84..c09e37ca4 100644 --- a/webui/src/pages/admin-dashboard/components/data-grid-filter-operators.tsx +++ b/webui/src/pages/admin-dashboard/components/data-grid-filter-operators.tsx @@ -11,39 +11,9 @@ * SPDX-License-Identifier: EPL-2.0 *****************************************************************************/ -import type { SyntheticEvent } from "react"; -import { FC } from 'react'; -import { Autocomplete, TextField } from '@mui/material'; +import { MultiSelectFilterInput } from "./data-grid-filter"; import { GridFilterOperator, GridFilterInputValueProps } from '@mui/x-data-grid'; -/** - * Custom multi-select filter input component for DataGrid columns. - * Renders an Autocomplete with multiple selection support. - */ -export const MultiSelectFilterInput: FC = ({ - item, - applyValue, - options -}) => { - const handleChange = (_event: SyntheticEvent, newValue: string[]) => { - applyValue({ ...item, value: newValue }); - }; - - return ( - ( - - )} - sx={{ minWidth: 150, mt: 'auto' }} - /> - ); -}; - /** * Creates filter operators for single-value columns with multi-select capability. * Includes "is any of" and "is none of" operators. diff --git a/webui/src/pages/admin-dashboard/components/data-grid-filter.tsx b/webui/src/pages/admin-dashboard/components/data-grid-filter.tsx new file mode 100644 index 000000000..ac38980b4 --- /dev/null +++ b/webui/src/pages/admin-dashboard/components/data-grid-filter.tsx @@ -0,0 +1,45 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import type { SyntheticEvent } from "react"; +import { FC } from 'react'; +import { Autocomplete, TextField } from '@mui/material'; +import { GridFilterInputValueProps } from '@mui/x-data-grid'; + +/** + * Custom multi-select filter input component for DataGrid columns. + * Renders an Autocomplete with multiple selection support. + */ +export const MultiSelectFilterInput: FC = ({ + item, + applyValue, + options +}) => { + const handleChange = (_event: SyntheticEvent, newValue: string[]) => { + applyValue({ ...item, value: newValue }); + }; + + return ( + ( + + )} + sx={{ minWidth: 150, mt: 'auto' }} + /> + ); +}; diff --git a/webui/src/pages/admin-dashboard/components/index.ts b/webui/src/pages/admin-dashboard/components/index.ts index f1d17d829..527f00a8a 100644 --- a/webui/src/pages/admin-dashboard/components/index.ts +++ b/webui/src/pages/admin-dashboard/components/index.ts @@ -11,8 +11,8 @@ * SPDX-License-Identifier: EPL-2.0 *****************************************************************************/ +export { MultiSelectFilterInput } from './data-grid-filter'; export { - MultiSelectFilterInput, createMultiSelectFilterOperators, createArrayContainsFilterOperators } from './data-grid-filter-operators'; diff --git a/webui/src/pages/admin-dashboard/customers/customer-details.tsx b/webui/src/pages/admin-dashboard/customers/customer-details.tsx new file mode 100644 index 000000000..d563066c0 --- /dev/null +++ b/webui/src/pages/admin-dashboard/customers/customer-details.tsx @@ -0,0 +1,129 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { FC, useContext, useState, useEffect, useRef, useCallback } from "react"; +import { + Box, + Typography, + Button, + Alert, + LinearProgress +} from "@mui/material"; +import EditIcon from '@mui/icons-material/Edit'; +import { useParams } from 'react-router-dom'; +import { MainContext } from '../../../context'; +import type { Customer } from '../../../extension-registry-types'; +import { handleError } from '../../../utils'; +import { useAdminUsageStats } from '../usage-stats/use-usage-stats'; +import { GeneralDetails, UsageStats } from '../../../components/rate-limiting/customer'; +import { CustomerFormDialog } from './customer-form-dialog'; +import { CustomerMemberList } from './customer-member-list'; + +const CustomerDetailsLoading: FC = () => ( + + + +); + +const CustomerDetailsError: FC<{ message: string }> = ({ message }) => ( + + {message} + +); + +export const CustomerDetails: FC = () => { + const { customer: customerName } = useParams<{ customer: string }>(); + const abortController = useRef(new AbortController()); + const { service } = useContext(MainContext); + + const [customer, setCustomer] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [formDialogOpen, setFormDialogOpen] = useState(false); + + const { usageStats, error: statsError, startDate, setStartDate } = useAdminUsageStats(customerName); + + const loadCustomer = useCallback(async () => { + if (!customerName) return; + try { + setLoading(true); + setError(null); + const data = await service.admin.getCustomer(abortController.current, customerName); + setCustomer(data); + } catch (err) { + if ((err as { status?: number })?.status === 404) { + setError(`Customer "${customerName}" not found.`); + } else { + setError(handleError(err as Error)); + } + } finally { + setLoading(false); + } + }, [service, customerName]); + + useEffect(() => { + loadCustomer(); + return () => abortController.current.abort(); + }, [loadCustomer]); + + const handleFormSubmit = async (updatedCustomer: Customer) => { + if (customer) { + await service.admin.updateCustomer(abortController.current, customer.name, updatedCustomer); + await loadCustomer(); + } + }; + + if (loading) { + return ; + } + + if (error || statsError) { + return ; + } + + if (!customer) { + return null; + } + + return ( + <> + + + + {customer.name} + + + + } onClick={() => setFormDialogOpen(true)}> + Edit + + } + /> + + + + + setFormDialogOpen(false)} + onSubmit={handleFormSubmit} + /> + + ); +}; diff --git a/webui/src/pages/admin-dashboard/customers/customer-form-dialog.tsx b/webui/src/pages/admin-dashboard/customers/customer-form-dialog.tsx index dd974499a..0e91f6345 100644 --- a/webui/src/pages/admin-dashboard/customers/customer-form-dialog.tsx +++ b/webui/src/pages/admin-dashboard/customers/customer-form-dialog.tsx @@ -31,9 +31,9 @@ import { styled } from '@mui/material'; import type { SelectChangeEvent } from '@mui/material'; -import { type Customer, EnforcementState, type Tier } from "../../../extension-registry-types"; -import { MainContext } from "../../../context"; -import { handleError } from "../../../utils"; +import { type Customer, EnforcementState, type Tier } from '../../../extension-registry-types'; +import { MainContext } from '../../../context'; +import { handleError } from '../../../utils'; interface CustomerFormDialogProps { open: boolean; @@ -42,7 +42,6 @@ interface CustomerFormDialogProps { onSubmit: (formData: Customer) => Promise; } - const Code = styled('code')(({ theme }) => ({ fontFamily: 'source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace', backgroundColor: theme.palette.action.hover, // Subtle gray background diff --git a/webui/src/pages/admin-dashboard/customers/customer-member-list.tsx b/webui/src/pages/admin-dashboard/customers/customer-member-list.tsx new file mode 100644 index 000000000..4e5f54acf --- /dev/null +++ b/webui/src/pages/admin-dashboard/customers/customer-member-list.tsx @@ -0,0 +1,174 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { FunctionComponent, useEffect, useState, useContext, useRef } from 'react'; +import { + Box, + Typography, + Button, + Divider, + List, + ListItem, + ListItemAvatar, + Avatar, + ListItemText, IconButton, type PaperProps, Paper +} from '@mui/material'; +import { Link as RouterLink } from 'react-router-dom'; +import { AdminDashboardRoutes } from '../admin-routes'; +import { MainContext } from '../../../context'; +import { CustomerMembership, Customer, UserData, isError } from '../../../extension-registry-types'; +import { AddUserDialog } from '../../user/add-user-dialog'; +import DeleteIcon from '@mui/icons-material/Delete'; +import PersonAddIcon from '@mui/icons-material/PersonAdd'; +import { createRoute } from '../../../utils'; + +const sectionPaperProps: PaperProps = { elevation: 1, sx: { p: 3, mb: 3 } }; + +export const CustomerMemberList: FunctionComponent = props => { + const { service, handleError } = useContext(MainContext); + const [members, setMembers] = useState([]); + const [addDialogIsOpen, setAddDialogIsOpen] = useState(false); + const abortController = useRef(new AbortController()); + + const users = members.map(member => member.user); + + useEffect(() => { + fetchMembers(); + }, [props.customer]); + + useEffect(() => { + return () => { + abortController.current.abort(); + }; + }, []); + + const handleCloseAddDialog = async () => { + setAddDialogIsOpen(false); + fetchMembers(); + }; + + const handleOpenAddDialog = async () => { + setAddDialogIsOpen(true); + }; + + const handleAddUser = async (user: UserData) => { + try { + const result = await service.admin.addCustomerMember(abortController.current, props.customer.name, user); + if (isError(result)) { + throw result; + } + await fetchMembers(); + } catch (err) { + handleError(err); + } + }; + + const handleRemoveUser = async (user: UserData) => { + try { + const result = await service.admin.removeCustomerMember(abortController.current, props.customer.name, user); + if (isError(result)) { + throw result; + } + await fetchMembers(); + } catch (err) { + handleError(err); + } + }; + + const fetchMembers = async () => { + try { + const membershipList = await service.admin.getCustomerMembers(abortController.current, props.customer.name); + const members = membershipList.customerMemberships; + setMembers(members); + } catch (err) { + handleError(err); + } + }; + + return + + Members + + + + {members.length === 0 ? ( + + No members assigned to this customer. + + ) : ( + + {users.map(user => ( + handleRemoveUser(user)} title='Remove member'> + + + } + > + + + + + {user.loginName} + + } + secondary={user.fullName} + /> + + ))} + + )} + + + + + {/*{members.length ?*/} + {/* */} + {/* {members.map(member =>*/} + {/* changeRole(member, role)}*/} + {/* onRemoveUser={() => changeRole(member, 'remove')} />)}*/} + {/* :*/} + {/* There are no members assigned yet.}*/} + + member.user)} + onClose={handleCloseAddDialog} + onAddUser={handleAddUser} + /> + ; +}; + +export interface CustomerMemberListProps { + customer: Customer; +} \ No newline at end of file diff --git a/webui/src/pages/admin-dashboard/customers/customers.tsx b/webui/src/pages/admin-dashboard/customers/customers.tsx index a4d9404ad..2ec04f58a 100644 --- a/webui/src/pages/admin-dashboard/customers/customers.tsx +++ b/webui/src/pages/admin-dashboard/customers/customers.tsx @@ -11,7 +11,7 @@ * SPDX-License-Identifier: EPL-2.0 *****************************************************************************/ -import { FC, useContext, useState, useEffect, useRef, useCallback, useMemo } from "react"; +import { FC, useContext, useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { Box, Button, @@ -27,14 +27,14 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import EditIcon from "@mui/icons-material/Edit"; import DeleteIcon from "@mui/icons-material/Delete"; import AddIcon from "@mui/icons-material/Add"; -import BarChartIcon from "@mui/icons-material/BarChart"; +import VisibilityIcon from "@mui/icons-material/Visibility"; import { MainContext } from "../../../context"; import type { Customer } from "../../../extension-registry-types"; import { CustomerFormDialog } from "./customer-form-dialog"; import { DeleteCustomerDialog } from "./delete-customer-dialog"; -import { handleError } from "../../../utils"; +import { createRoute, handleError } from "../../../utils"; import { createMultiSelectFilterOperators, createArrayContainsFilterOperators } from "../components"; -import { AdminDashboardRoutes } from "../admin-dashboard"; +import { AdminDashboardRoutes } from "../admin-routes"; import { Link } from "react-router-dom"; export const Customers: FC = () => { @@ -181,10 +181,10 @@ export const Customers: FC = () => { - + { ]; return ( - + Customer Management diff --git a/webui/src/pages/admin-dashboard/logs/logs.tsx b/webui/src/pages/admin-dashboard/logs/logs.tsx index 880ba68d6..cee67cbc8 100644 --- a/webui/src/pages/admin-dashboard/logs/logs.tsx +++ b/webui/src/pages/admin-dashboard/logs/logs.tsx @@ -136,16 +136,7 @@ export const Logs: FC = () => { ]; return ( - + { } }); + export const PublisherAdmin: FunctionComponent = () => { + const { publisher: publisherParam } = useParams<{ publisher: string }>(); + const navigate = useNavigate(); const { pageSettings, service, user, handleError } = useContext(MainContext); const abortController = useRef(new AbortController()); @@ -28,24 +34,20 @@ export const PublisherAdmin: FunctionComponent = () => { }, []); const [loading, setLoading] = useState(false); - const [inputValue, setInputValue] = useState(''); - const onChangeInput = (name: string) => { - setInputValue(name); - }; const [publisher, setPublisher] = useState(); const [notFound, setNotFound] = useState(''); - const fetchPublisher = async () => { - const publisherName = inputValue; + + const fetchPublisher = useCallback(async (publisherName: string) => { try { setLoading(true); if (publisherName === '') { setNotFound(''); setPublisher(undefined); } else { - const publisher = await service.admin.getPublisherInfo(abortController.current, 'github', publisherName); + const pub = await service.admin.getPublisherInfo(abortController.current, 'github', publisherName); setNotFound(''); - setPublisher(publisher); + setPublisher(pub); } setLoading(false); } catch (err) { @@ -57,10 +59,26 @@ export const PublisherAdmin: FunctionComponent = () => { } setLoading(false); } + }, [service, handleError]); + + useEffect(() => { + if (publisherParam) { + fetchPublisher(publisherParam); + } + }, [publisherParam, fetchPublisher]); + + const handleSubmit = (inputValue: string) => { + if (inputValue) { + navigate(`${AdminDashboardRoutes.PUBLISHER_ADMIN}/${inputValue}`); + } else { + navigate(AdminDashboardRoutes.PUBLISHER_ADMIN); + } }; const handleUpdate = () => { - fetchPublisher(); + if (publisherParam) { + fetchPublisher(publisherParam); + } }; let listContainer: ReactNode = ''; @@ -78,7 +96,7 @@ export const PublisherAdmin: FunctionComponent = () => { return ] + [ {}} />] } listContainer={listContainer} loading={loading} diff --git a/webui/src/pages/admin-dashboard/usage-stats/usage-stats.tsx b/webui/src/pages/admin-dashboard/usage-stats/usage-stats.tsx index 4bc0c46d7..0cc455446 100644 --- a/webui/src/pages/admin-dashboard/usage-stats/usage-stats.tsx +++ b/webui/src/pages/admin-dashboard/usage-stats/usage-stats.tsx @@ -11,17 +11,17 @@ * SPDX-License-Identifier: EPL-2.0 *****************************************************************************/ -import { FC, useContext, useState, useEffect, useRef, useMemo, useCallback } from "react"; +import { FC, useContext, useState, useEffect, useRef, useMemo } from "react"; import { Box, Alert } from "@mui/material"; import { useParams, useNavigate } from "react-router-dom"; import { MainContext } from "../../../context"; -import type { UsageStats, Customer } from "../../../extension-registry-types"; +import type { Customer } from "../../../extension-registry-types"; import { handleError } from "../../../utils"; -import { AdminDashboardRoutes } from "../admin-dashboard"; +import { AdminDashboardRoutes } from "../admin-routes"; import { SearchListContainer } from "../search-list-container"; import { CustomerSearch } from "./usage-stats-search"; -import { UsageStatsChart } from "./usage-stats-chart"; -import { getDefaultStartDate } from "./usage-stats-utils"; +import { UsageStatsChart } from "../../../components/rate-limiting/usage-stats/usage-stats-chart"; +import { useAdminUsageStats } from "./use-usage-stats"; export const UsageStatsView: FC = () => { const { customer } = useParams<{ customer: string }>(); @@ -31,10 +31,9 @@ export const UsageStatsView: FC = () => { const [customers, setCustomers] = useState([]); const [customersLoading, setCustomersLoading] = useState(true); - const [usageStats, setUsageStats] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [startDate, setStartDate] = useState(getDefaultStartDate); + const [customersError, setCustomersError] = useState(null); + + const { usageStats, loading, error: statsError, startDate, setStartDate } = useAdminUsageStats(customer); // Load customers for autocomplete useEffect(() => { @@ -44,7 +43,7 @@ export const UsageStatsView: FC = () => { const data = await service.admin.getCustomers(abortController.current); setCustomers(data.customers); } catch (err) { - setError(handleError(err as Error)); + setCustomersError(handleError(err as Error)); } finally { setCustomersLoading(false); } @@ -66,35 +65,7 @@ export const UsageStatsView: FC = () => { } }; - const loadUsageStats = useCallback(async () => { - if (!customer) { - setUsageStats([]); - setLoading(false); - return; - } - - try { - setLoading(true); - setError(null); - const data = await service.admin.getUsageStats( - abortController.current, - customer, - startDate - ); - setUsageStats(data.stats); - } catch (err) { - setError(handleError(err as Error)); - } finally { - setLoading(false); - } - }, [service, customer, startDate]); - - useEffect(() => { - if (customer) { - loadUsageStats(); - } - }, [loadUsageStats, customer]); - + const error = customersError || statsError; if (error) { return {error}; } diff --git a/webui/src/pages/admin-dashboard/usage-stats/use-usage-stats.ts b/webui/src/pages/admin-dashboard/usage-stats/use-usage-stats.ts new file mode 100644 index 000000000..c4724c871 --- /dev/null +++ b/webui/src/pages/admin-dashboard/usage-stats/use-usage-stats.ts @@ -0,0 +1,70 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { useContext, useState, useEffect, useRef, useCallback } from "react"; +import { MainContext } from "../../../context"; +import type { UsageStats } from "../../../extension-registry-types"; +import { handleError } from "../../../utils"; +import { getDefaultStartDate } from "../../../components/rate-limiting/usage-stats/usage-stats-utils"; +import { DateTime } from "luxon"; + +export const useAdminUsageStats = (customerName: string | undefined) => { + const abortController = useRef(new AbortController()); + const { service } = useContext(MainContext); + + const [usageStats, setUsageStats] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [internalStartDate, setInternalStartDate] = useState(getDefaultStartDate); + + const startDateRef = useRef(internalStartDate); + startDateRef.current = internalStartDate; + + const fetchUsageStats = useCallback(async (date: DateTime) => { + if (!customerName) { + setUsageStats([]); + setLoading(false); + return; + } + + try { + setLoading(true); + setError(null); + const data = await service.admin.getUsageStats( + abortController.current, + customerName, + date.toJSDate() + ); + setUsageStats(data.stats); + } catch (err) { + setError(handleError(err as Error)); + } finally { + setLoading(false); + } + }, [service, customerName]); + + const setStartDate = useCallback((date: DateTime) => { + setInternalStartDate(date); + fetchUsageStats(date); + }, [fetchUsageStats]); + + useEffect(() => { + fetchUsageStats(startDateRef.current); + return () => { + abortController.current.abort(); + abortController.current = new AbortController(); + }; + }, [fetchUsageStats]); + + return { usageStats, loading, error, startDate: internalStartDate, setStartDate }; +}; diff --git a/webui/src/pages/admin-dashboard/welcome.tsx b/webui/src/pages/admin-dashboard/welcome.tsx index b2c7c1bfb..96397ff03 100644 --- a/webui/src/pages/admin-dashboard/welcome.tsx +++ b/webui/src/pages/admin-dashboard/welcome.tsx @@ -12,7 +12,7 @@ import { FunctionComponent } from 'react'; import { Typography, Grid, Paper } from '@mui/material'; import { styled, Theme } from '@mui/material/styles'; import { Link } from 'react-router-dom'; -import { AdminDashboardRoutes } from './admin-dashboard'; +import { AdminDashboardRoutes } from './admin-routes'; export const Welcome: FunctionComponent = props => { return diff --git a/webui/src/pages/extension-detail/extension-detail-overview.tsx b/webui/src/pages/extension-detail/extension-detail-overview.tsx index 2adfbe570..eb63ec1a0 100644 --- a/webui/src/pages/extension-detail/extension-detail-overview.tsx +++ b/webui/src/pages/extension-detail/extension-detail-overview.tsx @@ -21,8 +21,8 @@ import { DelayedLoadIndicator } from '../../components/delayed-load-indicator'; import { SanitizedMarkdown } from '../../components/sanitized-markdown'; import { Timestamp } from '../../components/timestamp'; import { Extension, ExtensionReference, VERSION_ALIASES } from '../../extension-registry-types'; -import { ExtensionListRoutes } from '../extension-list/extension-list-container'; -import { ExtensionDetailRoutes } from './extension-detail'; +import { ExtensionListRoutes } from '../extension-list/extension-list-routes'; +import { ExtensionDetailRoutes } from './extension-detail-routes'; import { ExtensionDetailDownloadsMenu } from './extension-detail-downloads-menu'; export const ExtensionDetailOverview: FunctionComponent = props => { diff --git a/webui/src/pages/extension-detail/extension-detail-routes.ts b/webui/src/pages/extension-detail/extension-detail-routes.ts new file mode 100644 index 000000000..d30301a0f --- /dev/null +++ b/webui/src/pages/extension-detail/extension-detail-routes.ts @@ -0,0 +1,35 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { createRoute, getTargetPlatforms } from '../../utils'; + +export namespace ExtensionDetailRoutes { + export namespace Parameters { + export const NAMESPACE = ':namespace'; + export const NAME = ':name'; + export const TARGET = `:target(${getTargetPlatforms().join('|')})`; + export const VERSION = ':version?'; + } + + export const ROOT = 'extension'; + export const MAIN = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, Parameters.VERSION]); + export const MAIN_TARGET = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, Parameters.TARGET, Parameters.VERSION]); + export const LATEST = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME]); + export const LATEST_TARGET = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, Parameters.TARGET]); + export const PRE_RELEASE = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, 'pre-release']); + export const PRE_RELEASE_TARGET = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, Parameters.TARGET, 'pre-release']); + export const REVIEWS = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, 'reviews']); + export const REVIEWS_TARGET = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, Parameters.TARGET, 'reviews']); + export const CHANGES = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, 'changes']); + export const CHANGES_TARGET = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, Parameters.TARGET, 'changes']); +} diff --git a/webui/src/pages/extension-detail/extension-detail.tsx b/webui/src/pages/extension-detail/extension-detail.tsx index 5ce4f4bb1..55f973185 100644 --- a/webui/src/pages/extension-detail/extension-detail.tsx +++ b/webui/src/pages/extension-detail/extension-detail.tsx @@ -18,39 +18,19 @@ import SaveAltIcon from '@mui/icons-material/SaveAlt'; import VerifiedUserIcon from '@mui/icons-material/VerifiedUser'; import WarningIcon from '@mui/icons-material/Warning'; import { MainContext } from '../../context'; -import { createRoute, getTargetPlatforms } from '../../utils'; +import { createRoute } from '../../utils'; import { DelayedLoadIndicator } from '../../components/delayed-load-indicator'; import { HoverPopover } from '../../components/hover-popover'; import { Extension, UserData, isError } from '../../extension-registry-types'; import { TextDivider } from '../../components/text-divider'; import { ExtensionRatingStars } from './extension-rating-stars'; -import { NamespaceDetailRoutes } from '../namespace-detail/namespace-detail'; +import { NamespaceDetailRoutes } from '../namespace-detail/namespace-detail-routes'; import { ExtensionDetailOverview } from './extension-detail-overview'; import { ExtensionDetailChanges } from './extension-detail-changes'; import { ExtensionDetailReviews } from './extension-detail-reviews'; +import { ExtensionDetailRoutes } from './extension-detail-routes'; import styled from '@mui/material/styles/styled'; -export namespace ExtensionDetailRoutes { - export namespace Parameters { - export const NAMESPACE = ':namespace'; - export const NAME = ':name'; - export const TARGET = `:target(${getTargetPlatforms().join('|')})`; - export const VERSION = ':version?'; - } - - export const ROOT = 'extension'; - export const MAIN = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, Parameters.VERSION]); - export const MAIN_TARGET = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, Parameters.TARGET, Parameters.VERSION]); - export const LATEST = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME]); - export const LATEST_TARGET = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, Parameters.TARGET]); - export const PRE_RELEASE = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, 'pre-release']); - export const PRE_RELEASE_TARGET = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, Parameters.TARGET, 'pre-release']); - export const REVIEWS = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, 'reviews']); - export const REVIEWS_TARGET = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, Parameters.TARGET, 'reviews']); - export const CHANGES = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, 'changes']); - export const CHANGES_TARGET = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, Parameters.TARGET, 'changes']); -} - const alignVertically = { display: 'flex', alignItems: 'center' diff --git a/webui/src/pages/extension-list/extension-list-container.tsx b/webui/src/pages/extension-list/extension-list-container.tsx index 14da40846..d557255d5 100644 --- a/webui/src/pages/extension-list/extension-list-container.tsx +++ b/webui/src/pages/extension-list/extension-list-container.tsx @@ -11,15 +11,11 @@ import { FunctionComponent, useEffect, useState } from 'react'; import { Box } from '@mui/material'; import { useLocation } from 'react-router-dom'; -import { createRoute, addQuery } from '../../utils'; +import { addQuery } from '../../utils'; import { ExtensionCategory, SortOrder, SortBy } from '../../extension-registry-types'; import { ExtensionList } from './extension-list'; import { ExtensionListHeader } from './extension-list-header'; -export namespace ExtensionListRoutes { - export const MAIN = createRoute([]); -} - export const ExtensionListContainer: FunctionComponent = () => { const [searchQuery, setSearchQuery] = useState(''); diff --git a/webui/src/pages/extension-list/extension-list-item.tsx b/webui/src/pages/extension-list/extension-list-item.tsx index 9299b8f9d..7305e8da6 100644 --- a/webui/src/pages/extension-list/extension-list-item.tsx +++ b/webui/src/pages/extension-list/extension-list-item.tsx @@ -13,7 +13,7 @@ import { Link as RouteLink } from 'react-router-dom'; import { Paper, Typography, Box, Grid, Fade } from '@mui/material'; import SaveAltIcon from '@mui/icons-material/SaveAlt'; import { MainContext } from '../../context'; -import { ExtensionDetailRoutes } from '../extension-detail/extension-detail'; +import { ExtensionDetailRoutes } from '../extension-detail/extension-detail-routes'; import { SearchEntry } from '../../extension-registry-types'; import { ExtensionRatingStars } from '../extension-detail/extension-rating-stars'; import { createRoute } from '../../utils'; diff --git a/webui/src/pages/extension-list/extension-list-routes.ts b/webui/src/pages/extension-list/extension-list-routes.ts new file mode 100644 index 000000000..3ff30749f --- /dev/null +++ b/webui/src/pages/extension-list/extension-list-routes.ts @@ -0,0 +1,18 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { createRoute } from '../../utils'; + +export namespace ExtensionListRoutes { + export const MAIN = createRoute([]); +} diff --git a/webui/src/pages/namespace-detail/namespace-detail-routes.ts b/webui/src/pages/namespace-detail/namespace-detail-routes.ts new file mode 100644 index 000000000..3815416e3 --- /dev/null +++ b/webui/src/pages/namespace-detail/namespace-detail-routes.ts @@ -0,0 +1,23 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { createRoute } from '../../utils'; + +export namespace NamespaceDetailRoutes { + export namespace Parameters { + export const NAME = ':name'; + } + + export const ROOT = 'namespace'; + export const MAIN = createRoute([ROOT, Parameters.NAME]); +} diff --git a/webui/src/pages/namespace-detail/namespace-detail.tsx b/webui/src/pages/namespace-detail/namespace-detail.tsx index 4a6ce85ba..b13c34073 100644 --- a/webui/src/pages/namespace-detail/namespace-detail.tsx +++ b/webui/src/pages/namespace-detail/namespace-detail.tsx @@ -16,19 +16,9 @@ import TwitterIcon from '@mui/icons-material/Twitter'; import { useParams } from 'react-router-dom'; import { ExtensionListItem } from '../extension-list/extension-list-item'; import { MainContext } from '../../context'; -import { createRoute } from '../../utils'; import { DelayedLoadIndicator } from '../../components/delayed-load-indicator'; import { NamespaceDetails, isError, UrlString } from '../../extension-registry-types'; -export namespace NamespaceDetailRoutes { - export namespace Parameters { - export const NAME = ':name'; - } - - export const ROOT = 'namespace'; - export const MAIN = createRoute([ROOT, Parameters.NAME]); -} - export const NamespaceDetail: FunctionComponent = () => { const [loading, setLoading] = useState(true); const [truncateReadMore, setTruncateReadMore] = useState(true); diff --git a/webui/src/pages/user/add-namespace-member-dialog.tsx b/webui/src/pages/user/add-namespace-member-dialog.tsx index 292e603eb..56d8397c6 100644 --- a/webui/src/pages/user/add-namespace-member-dialog.tsx +++ b/webui/src/pages/user/add-namespace-member-dialog.tsx @@ -8,15 +8,12 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import { ChangeEvent, FunctionComponent, KeyboardEvent, useState, useContext, useEffect, useRef } from 'react'; +import { FunctionComponent, useContext, useRef } from 'react'; import { UserData } from '../..'; -import { - Dialog, DialogTitle, DialogContent, DialogContentText, TextField, DialogActions, Button, Popper, Fade, Paper, - Box, Avatar -} from '@mui/material'; import { Namespace, NamespaceMembership, isError } from '../../extension-registry-types'; import { NamespaceDetailConfigContext } from './user-settings-namespace-detail'; import { MainContext } from '../../context'; +import { AddUserDialog } from './add-user-dialog'; export interface AddMemberDialogProps { open: boolean; @@ -28,29 +25,17 @@ export interface AddMemberDialogProps { } export const AddMemberDialog: FunctionComponent = props => { - const { open } = props; const config = useContext(NamespaceDetailConfigContext); const { service, handleError } = useContext(MainContext); - const [foundUsers, setFoundUsers] = useState([]); - const [showUserPopper, setShowUserPopper] = useState(false); - const [popperTarget, setPopperTarget] = useState(undefined); const abortController = useRef(new AbortController()); - useEffect(() => { - return () => { - abortController.current.abort(); - }; - }, []); - const addUser = async (user: UserData) => { + const existingUsers = props.members.map(m => m.user); + + const handleAddUser = async (user: UserData) => { try { if (!props.namespace) { return; } - if (props.members.find(m => m.user.loginName === user.loginName && m.user.provider === user.provider)) { - setShowUserPopper(false); - handleError({ message: `User ${user.loginName} is already a member of ${props.namespace.name}.` }); - return; - } props.setLoadingState(true); const endpoint = props.namespace.roleUrl; const result = await service.setNamespaceMember(abortController.current, endpoint, user, config.defaultMemberRole ?? 'contributor'); @@ -58,106 +43,22 @@ export const AddMemberDialog: FunctionComponent = props => throw result; } props.setLoadingState(false); - onClose(); + props.onClose(); } catch (err) { - setShowUserPopper(false); props.setLoadingState(false); handleError(err); } }; - const onClose = () => { - setShowUserPopper(false); - props.onClose(); - }; - - const handleUserSearch = async (e: ChangeEvent) => { - const popperTarget = e.currentTarget; - setPopperTarget(popperTarget); - const val = popperTarget.value; - let showUserPopper = false; - let foundUsers: UserData[] = []; - if (val) { - const users = await service.getUserByName(abortController.current, val); - if (users) { - showUserPopper = true; - foundUsers = users; - } - } - setShowUserPopper(showUserPopper); - setFoundUsers(foundUsers); - }; - - return <> - - Add User to Namespace - - - Enter the Login Name of the User you want to add. - - { - if (e.key === "Enter" && foundUsers.length === 1) { - e.preventDefault(); - addUser(foundUsers[0]); - } - }} - /> - - - - - - - {({ TransitionProps }) => ( - - - { - foundUsers.filter(props.filterUsers).map(foundUser => { - return addUser(foundUser)} - key={'found' + foundUser.loginName} - sx={{ - display: 'flex', - height: 60, - alignItems: 'center', - '&:hover': { - cursor: 'pointer', - bgcolor: 'action.hover' - } - }} - > - - - {foundUser.loginName} - - - {foundUser.fullName} - - - - - - ; - }) - } - - - )} - - ; + return ( + + ); }; \ No newline at end of file diff --git a/webui/src/pages/user/add-user-dialog.tsx b/webui/src/pages/user/add-user-dialog.tsx new file mode 100644 index 000000000..ed477ebfc --- /dev/null +++ b/webui/src/pages/user/add-user-dialog.tsx @@ -0,0 +1,141 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { FC, useState, useContext, useEffect, useRef } from 'react'; +import { + Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Button, + TextField, Autocomplete, Box, Avatar +} from '@mui/material'; +import type { UserData } from '../../extension-registry-types'; +import { MainContext } from '../../context'; + +export interface AddUserDialogProps { + open: boolean; + title: string; + description: string; + existingUsers: UserData[]; + onClose: () => void; + onAddUser: (user: UserData) => void; + filterUsers?: (user: UserData) => boolean; +} + +export const AddUserDialog: FC = ({ + open, + title, + description, + existingUsers, + onClose, + onAddUser, + filterUsers: externalFilter +}) => { + const { service, handleError } = useContext(MainContext); + const [options, setOptions] = useState([]); + const [loading, setLoading] = useState(false); + const abortController = useRef(new AbortController()); + const debounceTimeout = useRef>(); + + useEffect(() => { + return () => { + abortController.current.abort(); + clearTimeout(debounceTimeout.current); + }; + }, []); + + const isUserExcluded = (user: UserData) => + existingUsers.some(u => u.loginName === user.loginName && u.provider === user.provider) + || (externalFilter && !externalFilter(user)); + + const handleInputChange = (_: unknown, value: string) => { + clearTimeout(debounceTimeout.current); + if (!value) { + setOptions([]); + setLoading(false); + return; + } + setLoading(true); + debounceTimeout.current = setTimeout(async () => { + const users = await service.getUserByName(abortController.current, value); + if (users) { + setOptions(users); + } + setLoading(false); + }, 300); + }; + + const handleSelect = (_: unknown, user: UserData | null) => { + if (!user) return; + if (isUserExcluded(user)) { + handleError({ message: `User ${user.loginName} is already added.` }); + return; + } + onAddUser(user); + onClose(); + }; + + const handleClose = () => { + setOptions([]); + onClose(); + }; + + return ( + + {title} + + {description} + + options={options} + loading={loading} + filterOptions={(opts) => opts.filter(u => !isUserExcluded(u))} + getOptionLabel={(option) => option.loginName} + isOptionEqualToValue={(option, value) => + option.loginName === value.loginName && option.provider === value.provider + } + onInputChange={handleInputChange} + onChange={handleSelect} + renderOption={(props, user) => ( + + + + {user.loginName} + {user.fullName} + + + )} + renderInput={(params) => ( + + )} + /> + + + + + + ); +}; diff --git a/webui/src/pages/user/avatar.tsx b/webui/src/pages/user/avatar.tsx index b2ceb5b6c..629e223c8 100644 --- a/webui/src/pages/user/avatar.tsx +++ b/webui/src/pages/user/avatar.tsx @@ -11,8 +11,8 @@ import { FunctionComponent, useContext, useRef, useState } from 'react'; import { Avatar, Menu, Typography, MenuItem, Link, Divider, IconButton } from '@mui/material'; import { Link as RouteLink } from 'react-router-dom'; -import { UserSettingsRoutes } from './user-settings'; -import { AdminDashboardRoutes } from '../admin-dashboard/admin-dashboard'; +import { UserSettingsRoutes } from './user-settings-routes'; +import { AdminDashboardRoutes } from '../admin-dashboard/admin-routes'; import { MainContext } from '../../context'; import { LogoutForm } from './logout'; 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 1e9b1c34c..889f1c5e0 100644 --- a/webui/src/pages/user/user-namespace-extension-list-item.tsx +++ b/webui/src/pages/user/user-namespace-extension-list-item.tsx @@ -16,9 +16,9 @@ import { Link as RouteLink, useNavigate } from 'react-router-dom'; import { MainContext } from '../../context'; import { createRoute } from '../../utils'; import { Timestamp } from '../../components/timestamp'; -import { ExtensionDetailRoutes } from '../extension-detail/extension-detail'; +import { ExtensionDetailRoutes } from '../extension-detail/extension-detail-routes'; import DeleteIcon from '@mui/icons-material/Delete'; -import { UserSettingsRoutes } from './user-settings'; +import { UserSettingsRoutes } from './user-settings-routes'; const getOpacity = (extension: Extension) => { if (extension.deprecated) { diff --git a/webui/src/pages/user/user-setting-tabs.tsx b/webui/src/pages/user/user-setting-tabs.tsx index 7bedff831..da8f27ed2 100644 --- a/webui/src/pages/user/user-setting-tabs.tsx +++ b/webui/src/pages/user/user-setting-tabs.tsx @@ -12,7 +12,7 @@ import { ChangeEvent, ReactElement } from 'react'; import { Tabs, Tab, useTheme, useMediaQuery } from '@mui/material'; import { useNavigate, useParams } from 'react-router-dom'; import { createRoute } from '../../utils'; -import { UserSettingsRoutes } from './user-settings'; +import { UserSettingsRoutes } from './user-settings-routes'; export const UserSettingTabs = (): ReactElement => { @@ -42,6 +42,7 @@ export const UserSettingTabs = (): ReactElement => { + ); }; \ No newline at end of file diff --git a/webui/src/pages/user/user-settings-customer-detail.tsx b/webui/src/pages/user/user-settings-customer-detail.tsx new file mode 100644 index 000000000..1e091ff73 --- /dev/null +++ b/webui/src/pages/user/user-settings-customer-detail.tsx @@ -0,0 +1,39 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { FC } from 'react'; +import { Box, Typography } from '@mui/material'; +import type { Customer } from '../../extension-registry-types'; +import { useUsageStats } from '../../components/rate-limiting/usage-stats/use-usage-stats'; +import { UsageStats } from '../../components/rate-limiting/customer'; + +export interface UserSettingsCustomerDetailProps { + customer: Customer; +} + +export const UserSettingsCustomerDetail: FC = ({ customer }) => { + const { usageStats, startDate, setStartDate } = useUsageStats(customer.name); + + return ( + + {customer.name} + + + ); +}; diff --git a/webui/src/pages/user/user-settings-customers.tsx b/webui/src/pages/user/user-settings-customers.tsx new file mode 100644 index 000000000..03d0b394f --- /dev/null +++ b/webui/src/pages/user/user-settings-customers.tsx @@ -0,0 +1,120 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { FunctionComponent, ReactNode, useContext, useEffect, useState, useRef } from 'react'; +import { Box, Typography, Tabs, Tab, useTheme, useMediaQuery } from '@mui/material'; +import { Customer } from '../../extension-registry-types'; +import { DelayedLoadIndicator } from '../../components/delayed-load-indicator'; +import { MainContext } from '../../context'; +import { UserSettingsCustomerDetail } from './user-settings-customer-detail'; + +interface CustomerTabsProps { + chosenCustomer: Customer; + onChange: (value: Customer) => void; + customers: Customer[]; +} + +const CustomersTabs = (props: CustomerTabsProps) => { + const theme = useTheme(); + const isATablet = useMediaQuery(theme.breakpoints.down('md')); + return ( + props.onChange(value)} + variant={isATablet ? 'scrollable' : 'standard'} + scrollButtons={isATablet ? 'auto' : false} + indicatorColor='secondary' + sx={{ width: { xs: '80%', sm: '80%', md: '80%', lg: '160px', xl: '160px' } }} + > + {props.customers.map(customer => ( + + ))} + + ); +}; + +export const UserSettingsCustomers: FunctionComponent = () => { + const [loading, setLoading] = useState(true); + const [customers, setCustomers] = useState([]); + const [chosenCustomer, setChosenCustomer] = useState(); + const { service, handleError } = useContext(MainContext); + const abortController = useRef(new AbortController()); + + useEffect(() => { + loadCustomers(); + return () => { + abortController.current.abort(); + }; + }, []); + + const loadCustomers = async (): Promise => { + try { + const data = await service.getCustomers(abortController.current); + const chosen = data.length ? data[0] : undefined; + setCustomers(data); + setChosenCustomer(chosen); + setLoading(false); + } catch (err) { + handleError(err); + setLoading(false); + } + }; + + let customerContainer: ReactNode = null; + if (customers.length > 0 && chosenCustomer) { + customerContainer = ( + + + + + ); + } else if (!loading) { + customerContainer = ( + + You are not a member of any rate limiting customer group. + + ); + } + + return ( + <> + + Rate Limiting + + + + {customerContainer} + + + ); +}; diff --git a/webui/src/pages/user/user-settings-delete-extension.tsx b/webui/src/pages/user/user-settings-delete-extension.tsx index b3f122d1c..461abb11a 100644 --- a/webui/src/pages/user/user-settings-delete-extension.tsx +++ b/webui/src/pages/user/user-settings-delete-extension.tsx @@ -15,7 +15,7 @@ import { isError, Extension, TargetPlatformVersion } from '../../extension-regis import { DelayedLoadIndicator } from '../../components/delayed-load-indicator'; import { ExtensionVersionContainer } from '../admin-dashboard/extension-version-container'; import { useNavigate } from 'react-router'; -import { UserSettingsRoutes } from './user-settings'; +import { UserSettingsRoutes } from './user-settings-routes'; export const UserSettingsDeleteExtension: FunctionComponent = props => { const navigate = useNavigate(); diff --git a/webui/src/pages/user/user-settings-namespace-detail.tsx b/webui/src/pages/user/user-settings-namespace-detail.tsx index cbc60015e..bbefce89c 100644 --- a/webui/src/pages/user/user-settings-namespace-detail.tsx +++ b/webui/src/pages/user/user-settings-namespace-detail.tsx @@ -14,7 +14,7 @@ import { Box, Button, Link, Paper, Grid, Typography } from '@mui/material'; import { styled, Theme } from '@mui/material/styles'; import WarningIcon from '@mui/icons-material/Warning'; import { UserNamespaceExtensionListContainer } from './user-namespace-extension-list'; -import { AdminDashboardRoutes } from '../admin-dashboard/admin-dashboard'; +import { AdminDashboardRoutes } from '../admin-dashboard/admin-routes'; import { Namespace, UserData } from '../../extension-registry-types'; import { NamespaceChangeDialog } from '../admin-dashboard/namespace-change-dialog'; import { UserNamespaceMemberList } from './user-namespace-member-list'; @@ -23,6 +23,8 @@ import { UserNamespaceDetails } from './user-namespace-details'; export interface NamespaceDetailConfig { defaultMemberRole?: 'contributor' | 'owner'; } + +// eslint-disable-next-line react-refresh/only-export-components export const NamespaceDetailConfigContext = createContext({}); const NamespaceDetailContainer = styled(Grid)(({ theme }: { theme: Theme }) => ({ diff --git a/webui/src/pages/user/user-settings-routes.ts b/webui/src/pages/user/user-settings-routes.ts new file mode 100644 index 000000000..15e4076d7 --- /dev/null +++ b/webui/src/pages/user/user-settings-routes.ts @@ -0,0 +1,25 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { createRoute } from '../../utils'; + +export namespace UserSettingsRoutes { + export const ROOT = createRoute(['user-settings']); + export const MAIN = createRoute([ROOT, ':tab']); + export const PROFILE = createRoute([ROOT, 'profile']); + export const TOKENS = createRoute([ROOT, 'tokens']); + export const NAMESPACES = createRoute([ROOT, 'namespaces']); + export const EXTENSIONS = createRoute([ROOT, 'extensions']); + export const DELETE_EXTENSION = createRoute([ROOT, 'extensions', ':namespace', ':extension', 'delete']); + export const CUSTOMERS = createRoute([ROOT, 'customers']); +} diff --git a/webui/src/pages/user/user-settings-tokens.tsx b/webui/src/pages/user/user-settings-tokens.tsx index 88a001362..8615c332b 100644 --- a/webui/src/pages/user/user-settings-tokens.tsx +++ b/webui/src/pages/user/user-settings-tokens.tsx @@ -16,7 +16,7 @@ import { Timestamp } from '../../components/timestamp'; import { PersonalAccessToken } from '../../extension-registry-types'; import { MainContext } from '../../context'; import { GenerateTokenDialog } from './generate-token-dialog'; -import { UserSettingsRoutes } from './user-settings'; +import { UserSettingsRoutes } from './user-settings-routes'; import styled from '@mui/material/styles/styled'; const link = ({ theme }: { theme: Theme }) => ({ @@ -216,10 +216,3 @@ export const UserSettingsTokens: FunctionComponent = () => { ; }; - -export namespace UserSettingsTokens { - export interface State { - tokens: PersonalAccessToken[]; - loading: boolean; - } -} \ No newline at end of file diff --git a/webui/src/pages/user/user-settings.tsx b/webui/src/pages/user/user-settings.tsx index c06982002..b3b96872e 100644 --- a/webui/src/pages/user/user-settings.tsx +++ b/webui/src/pages/user/user-settings.tsx @@ -12,28 +12,18 @@ import { FunctionComponent, ReactNode, useContext } from 'react'; import { Helmet } from 'react-helmet-async'; import { Grid, Container, Box, Typography, Link } from '@mui/material'; import { useParams } from 'react-router-dom'; -import { createRoute } from '../../utils'; import { DelayedLoadIndicator } from '../../components/delayed-load-indicator'; import { UserSettingTabs } from './user-setting-tabs'; import { UserSettingsTokens } from './user-settings-tokens'; import { UserSettingsProfile } from './user-settings-profile'; import { UserSettingsNamespaces } from './user-settings-namespaces'; import { UserSettingsExtensions } from './user-settings-extensions'; +import { UserSettingsCustomers } from './user-settings-customers'; import { MainContext } from '../../context'; import { UserData } from '../../extension-registry-types'; import { LoginComponent } from '../../default/login'; import { UserSettingsDeleteExtension } from './user-settings-delete-extension'; -export namespace UserSettingsRoutes { - export const ROOT = createRoute(['user-settings']); - export const MAIN = createRoute([ROOT, ':tab']); - export const PROFILE = createRoute([ROOT, 'profile']); - export const TOKENS = createRoute([ROOT, 'tokens']); - export const NAMESPACES = createRoute([ROOT, 'namespaces']); - export const EXTENSIONS = createRoute([ROOT, 'extensions']); - export const DELETE_EXTENSION = createRoute([ROOT, 'extensions', ':namespace', ':extension', 'delete']); -} - export const UserSettings: FunctionComponent = props => { const { pageSettings, user, loginProviders } = useContext(MainContext); @@ -53,6 +43,8 @@ export const UserSettings: FunctionComponent = props => { return ; case 'extensions': return ; + case 'customers': + return ; default: return null; } @@ -79,7 +71,7 @@ return (log in); : null; } - return + return diff --git a/webui/yarn.lock b/webui/yarn.lock index 902efde0a..20b298b6f 100644 --- a/webui/yarn.lock +++ b/webui/yarn.lock @@ -317,7 +317,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.28.4": +"@babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.28.4": version: 7.28.6 resolution: "@babel/runtime@npm:7.28.6" checksum: 10/fbcd439cb74d4a681958eb064c509829e3f46d8a4bfaaf441baa81bb6733d1e680bccc676c813883d7741bcaada1d0d04b15aa320ef280b5734e2192b50decf9 @@ -1904,6 +1904,13 @@ __metadata: languageName: node linkType: hard +"@types/luxon@npm:^3.7.1": + version: 3.7.1 + resolution: "@types/luxon@npm:3.7.1" + checksum: 10/c7bc164c278393ea0be938f986c74b4cddfab9013b1aff4495b016f771ded1d5b7b7b4825b2c7f0b8799edce19c5f531c28ff434ab3dedf994ac2d99a20fd4c4 + languageName: node + linkType: hard + "@types/markdown-it@npm:^14.1.0, @types/markdown-it@npm:^14.1.2": version: 14.1.2 resolution: "@types/markdown-it@npm:14.1.2" @@ -3151,15 +3158,6 @@ __metadata: languageName: node linkType: hard -"date-fns@npm:^2.30.0": - version: 2.30.0 - resolution: "date-fns@npm:2.30.0" - dependencies: - "@babel/runtime": "npm:^7.21.0" - checksum: 10/70b3e8ea7aaaaeaa2cd80bd889622a4bcb5d8028b4de9162cbcda359db06e16ff6e9309e54eead5341e71031818497f19aaf9839c87d1aba1e27bb4796e758a9 - languageName: node - linkType: hard - "debug@npm:2.6.9": version: 2.6.9 resolution: "debug@npm:2.6.9" @@ -3833,6 +3831,15 @@ __metadata: languageName: node linkType: hard +"eslint-plugin-react-refresh@npm:^0.5.2": + version: 0.5.2 + resolution: "eslint-plugin-react-refresh@npm:0.5.2" + peerDependencies: + eslint: ^9 || ^10 + checksum: 10/155a2e66d74866352f023b2a2d9b0daf1fb3033638851321caafa48fe9c9984830acc089d3832348ff2df2848791db96d17bd43639860b045b9f7ee4e5f86dcc + languageName: node + linkType: hard + "eslint-plugin-react@npm:^7.37.0": version: 7.37.5 resolution: "eslint-plugin-react@npm:7.37.5" @@ -5357,6 +5364,13 @@ __metadata: languageName: node linkType: hard +"luxon@npm:^3.7.2": + version: 3.7.2 + resolution: "luxon@npm:3.7.2" + checksum: 10/b24cd205ed306ce7415991687897dcc4027921ae413c9116590bc33a95f93b86ce52cf74ba72b4f5c5ab1c10090517f54ac8edfb127c049e0bf55b90dc2260be + languageName: node + linkType: hard + "make-error@npm:^1.1.1": version: 1.3.6 resolution: "make-error@npm:1.3.6" @@ -5849,6 +5863,7 @@ __metadata: "@types/dompurify": "npm:^3.0.2" "@types/express": "npm:^4.17.21" "@types/lodash": "npm:^4.14.195" + "@types/luxon": "npm:^3.7.1" "@types/markdown-it": "npm:^14.1.0" "@types/mocha": "npm:^10.0.0" "@types/node": "npm:^22.0.0" @@ -5866,14 +5881,15 @@ __metadata: chai: "npm:^4.3.0" clipboard-copy: "npm:^4.0.1" clsx: "npm:^1.2.1" - date-fns: "npm:^2.30.0" dompurify: "npm:^3.0.4" eslint: "npm:^9.39.0" eslint-plugin-react: "npm:^7.37.0" + eslint-plugin-react-refresh: "npm:^0.5.2" express: "npm:^4.21.0" express-rate-limit: "npm:^7.4.0" fetch-retry: "npm:^5.0.6" lodash: "npm:^4.17.21" + luxon: "npm:^3.7.2" markdown-it: "npm:^14.1.0" markdown-it-anchor: "npm:^9.2.0" mocha: "npm:^11.7.0"