From 45fe0f1c46ff4424e5e9a69edc69f0b02cb38118 Mon Sep 17 00:00:00 2001 From: Martin Lowe Date: Fri, 17 Apr 2026 12:08:00 -0400 Subject: [PATCH 1/7] fix: Update development + local readme for CLI build process --- README.md | 1 + doc/development.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 677e32061..3c03d637b 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ Click _Open Browser_ on port 3000 to see the running web application. ### cli + * `yarn prepare` — generates required local version data and builds the library and `ovsx` command * `yarn build` — build the library and `ovsx` command * `yarn watch` — watch (build continuously) diff --git a/doc/development.md b/doc/development.md index f039ab0e3..438299410 100644 --- a/doc/development.md +++ b/doc/development.md @@ -143,7 +143,7 @@ To run the Open VSX registry in a development environment, you can use `docker c - cd cli - yarn install - - yarn build + - yarn prepare - cd server From 071592a5ec203ffcf4455195f5681edbd08ca81f Mon Sep 17 00:00:00 2001 From: Martin Lowe Date: Fri, 17 Apr 2026 14:23:39 -0400 Subject: [PATCH 2/7] feat: Add option for admins to delete empty namespaces Creates a new admin-only endpoint that enables deletion through the Namespace section of the admin dashboard. This will appear as a new button in the namespace search result with a modal to prevent accidental deletion. --- .../org/eclipse/openvsx/admin/AdminAPI.java | 38 +++- .../eclipse/openvsx/admin/AdminService.java | 78 ++++++-- .../eclipse/openvsx/admin/AdminAPITest.java | 169 +++++++++++++++--- webui/src/extension-registry-service.ts | 18 ++ .../namespace-delete-dialog.tsx | 98 ++++++++++ .../user/user-settings-namespace-detail.tsx | 28 ++- 6 files changed, 393 insertions(+), 36 deletions(-) create mode 100644 webui/src/pages/admin-dashboard/namespace-delete-dialog.tsx diff --git a/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java b/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java index 5f6962bc6..af3a8fd3e 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java @@ -33,7 +33,12 @@ import org.eclipse.openvsx.json.UserPublishInfoJson; import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.search.SearchUtilService; -import org.eclipse.openvsx.util.*; +import org.eclipse.openvsx.util.ErrorResultException; +import org.eclipse.openvsx.util.LogService; +import org.eclipse.openvsx.util.NamingUtil; +import org.eclipse.openvsx.util.NotFoundException; +import org.eclipse.openvsx.util.TimeUtil; +import org.eclipse.openvsx.util.UrlUtil; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.util.Streamable; @@ -41,6 +46,7 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -394,6 +400,36 @@ private String createAdminNamespaceUrl(NamespaceJson namespace) { return UrlUtil.createApiUrl(UrlUtil.getBaseUrl(), "admin", "namespace", namespace.getName()); } + @DeleteMapping( + path = "/admin/namespace/{namespaceName}" + ) + @Operation(summary = "Delete a namespace") + @ApiResponse( + responseCode = "200", + description = "A success message is returned in JSON format" + ) + @ApiResponse( + responseCode = "403", + description = "An error message is returned in JSON format", + content = @Content(schema = @Schema(implementation = ResultJson.class)) + ) + @ApiResponse( + responseCode = "404", + description = "An error message is returned in JSON format", + content = @Content(schema = @Schema(implementation = ResultJson.class)) + ) + public ResponseEntity deleteNamespace(@PathVariable String namespaceName) { + try { + var adminUser = admins.checkAdminUser(); + return ResponseEntity.ok(admins.deleteNamespace(namespaceName, adminUser)); + } catch (NotFoundException exc) { + var json = NamespaceJson.error("Namespace not found: " + namespaceName); + return new ResponseEntity<>(json, HttpStatus.NOT_FOUND); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(ResultJson.class); + } + } + @PostMapping( path = "/admin/create-namespace", consumes = MediaType.APPLICATION_JSON_VALUE, diff --git a/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java b/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java index 20e2e99e2..b090c826a 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java @@ -9,8 +9,23 @@ ********************************************************************************/ package org.eclipse.openvsx.admin; -import jakarta.persistence.EntityManager; -import jakarta.transaction.Transactional; +import static org.eclipse.openvsx.entities.FileResource.CHANGELOG; +import static org.eclipse.openvsx.entities.FileResource.DOWNLOAD; +import static org.eclipse.openvsx.entities.FileResource.ICON; +import static org.eclipse.openvsx.entities.FileResource.LICENSE; +import static org.eclipse.openvsx.entities.FileResource.MANIFEST; +import static org.eclipse.openvsx.entities.FileResource.README; +import static org.eclipse.openvsx.entities.FileResource.VSIXMANIFEST; + +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; + import org.apache.commons.lang3.StringUtils; import org.eclipse.openvsx.ExtensionService; import org.eclipse.openvsx.ExtensionValidator; @@ -18,15 +33,31 @@ import org.eclipse.openvsx.accesstoken.AccessTokenService; import org.eclipse.openvsx.cache.CacheService; import org.eclipse.openvsx.eclipse.EclipseService; -import org.eclipse.openvsx.entities.*; -import org.eclipse.openvsx.json.*; +import org.eclipse.openvsx.entities.AdminStatistics; +import org.eclipse.openvsx.entities.Extension; +import org.eclipse.openvsx.entities.ExtensionReview; +import org.eclipse.openvsx.entities.ExtensionVersion; +import org.eclipse.openvsx.entities.Namespace; +import org.eclipse.openvsx.entities.PersonalAccessToken; +import org.eclipse.openvsx.entities.UserData; +import org.eclipse.openvsx.json.ChangeNamespaceJson; +import org.eclipse.openvsx.json.ExtensionJson; +import org.eclipse.openvsx.json.NamespaceJson; +import org.eclipse.openvsx.json.ResultJson; +import org.eclipse.openvsx.json.TargetPlatformVersionJson; +import org.eclipse.openvsx.json.UserPublishInfoJson; import org.eclipse.openvsx.mail.MailService; import org.eclipse.openvsx.migration.HandlerJobRequest; import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.scanning.ExtensionScanPersistenceService; import org.eclipse.openvsx.search.SearchUtilService; import org.eclipse.openvsx.storage.StorageUtilService; -import org.eclipse.openvsx.util.*; +import org.eclipse.openvsx.util.ErrorResultException; +import org.eclipse.openvsx.util.LogService; +import org.eclipse.openvsx.util.NamingUtil; +import org.eclipse.openvsx.util.NotFoundException; +import org.eclipse.openvsx.util.TimeUtil; +import org.eclipse.openvsx.util.UrlUtil; import org.jobrunr.scheduling.JobRequestScheduler; import org.jobrunr.scheduling.cron.Cron; import org.springframework.boot.context.event.ApplicationStartedEvent; @@ -34,11 +65,8 @@ import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; -import java.time.ZoneId; -import java.util.*; -import java.util.stream.Collectors; - -import static org.eclipse.openvsx.entities.FileResource.*; +import jakarta.persistence.EntityManager; +import jakarta.transaction.Transactional; @Component public class AdminService { @@ -250,6 +278,36 @@ protected ResultJson deleteExtension(ExtensionVersion extVersion, UserData admin return result; } + @Transactional(rollbackOn = ErrorResultException.class) + public ResultJson deleteNamespace(String namespaceName, UserData admin) + throws ErrorResultException { + var namespaceEntity = repositories.findNamespace(namespaceName); + if (namespaceEntity == null) { + throw new ErrorResultException("Namespace not found: " + namespaceName, HttpStatus.NOT_FOUND); + } + return deleteNamespace(namespaceEntity, admin); + } + + protected ResultJson deleteNamespace(Namespace namespace, UserData admin) { + var namespaceExtensions = repositories.findExtensions(namespace); + if (!namespaceExtensions.isEmpty()) { + throw new ErrorResultException("Cannot delete namespaces that contain extensions.", HttpStatus.BAD_REQUEST); + } + + var memberships = repositories.findMemberships(namespace); + for (var membership : memberships) { + entityManager.remove(membership); + } + + entityManager.remove(namespace); + // Clear cache for the namespace + cache.evictNamespaceDetails(namespace); + + var result = ResultJson.success("Deleted namespace " + namespace.getName()); + logs.logAction(admin, result); + return result; + } + private void removeExtensionVersion(ExtensionVersion extVersion) { // Clean up any pending scan jobs for this extension version // to prevent "file not found" errors after deletion diff --git a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java index 22a580713..23006b32d 100644 --- a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java @@ -9,11 +9,35 @@ ********************************************************************************/ package org.eclipse.openvsx.admin; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import io.micrometer.core.instrument.simple.SimpleMeterRegistry; -import jakarta.persistence.EntityManager; -import org.eclipse.openvsx.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import org.eclipse.openvsx.ExtensionService; +import org.eclipse.openvsx.ExtensionValidator; +import org.eclipse.openvsx.LocalRegistryService; +import org.eclipse.openvsx.MockTransactionTemplate; +import org.eclipse.openvsx.UpstreamRegistryService; +import org.eclipse.openvsx.UserService; import org.eclipse.openvsx.accesstoken.AccessTokenConfig; import org.eclipse.openvsx.accesstoken.AccessTokenService; import org.eclipse.openvsx.adapter.VSCodeIdService; @@ -21,9 +45,25 @@ import org.eclipse.openvsx.cache.LatestExtensionVersionCacheKeyGenerator; import org.eclipse.openvsx.eclipse.EclipseService; import org.eclipse.openvsx.eclipse.EclipseTokenService; -import org.eclipse.openvsx.entities.*; -import org.eclipse.openvsx.json.*; +import org.eclipse.openvsx.entities.AdminStatistics; +import org.eclipse.openvsx.entities.Extension; +import org.eclipse.openvsx.entities.ExtensionReview; +import org.eclipse.openvsx.entities.ExtensionVersion; +import org.eclipse.openvsx.entities.Namespace; +import org.eclipse.openvsx.entities.NamespaceMembership; +import org.eclipse.openvsx.entities.PersonalAccessToken; +import org.eclipse.openvsx.entities.UserData; +import org.eclipse.openvsx.json.AdminStatisticsJson; +import org.eclipse.openvsx.json.ChangeNamespaceJson; +import org.eclipse.openvsx.json.ExtensionJson; +import org.eclipse.openvsx.json.NamespaceJson; +import org.eclipse.openvsx.json.NamespaceMembershipJson; +import org.eclipse.openvsx.json.NamespaceMembershipListJson; +import org.eclipse.openvsx.json.ResultJson; +import org.eclipse.openvsx.json.UserJson; +import org.eclipse.openvsx.json.UserPublishInfoJson; import org.eclipse.openvsx.mail.MailService; +import org.eclipse.openvsx.metrics.ExtensionDownloadMetrics; import org.eclipse.openvsx.publish.ExtensionVersionIntegrityService; import org.eclipse.openvsx.publish.PublishExtensionVersionHandler; import org.eclipse.openvsx.repositories.RepositoryService; @@ -36,8 +76,13 @@ import org.eclipse.openvsx.security.OAuth2AttributesConfig; import org.eclipse.openvsx.security.OAuth2UserServices; import org.eclipse.openvsx.security.SecurityConfig; -import org.eclipse.openvsx.storage.*; -import org.eclipse.openvsx.metrics.ExtensionDownloadMetrics; +import org.eclipse.openvsx.storage.AwsStorageService; +import org.eclipse.openvsx.storage.AzureBlobStorageService; +import org.eclipse.openvsx.storage.CdnServiceConfig; +import org.eclipse.openvsx.storage.FileCacheDurationConfig; +import org.eclipse.openvsx.storage.GoogleCloudStorageService; +import org.eclipse.openvsx.storage.LocalStorageService; +import org.eclipse.openvsx.storage.StorageUtilService; import org.eclipse.openvsx.storage.log.DownloadCountService; import org.eclipse.openvsx.util.LogService; import org.eclipse.openvsx.util.TargetPlatform; @@ -46,7 +91,6 @@ import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.test.autoconfigure.web.client.AutoConfigureWebClient; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.context.TestConfiguration; @@ -62,18 +106,11 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.support.TransactionTemplate; -import java.time.LocalDateTime; -import java.util.*; -import java.util.function.Consumer; -import java.util.stream.Collectors; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.*; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import jakarta.persistence.EntityManager; @WebMvcTest(AdminAPI.class) @AutoConfigureWebClient @@ -557,6 +594,68 @@ void testCreateExistingNamespace() throws Exception { .andExpect(content().json(errorJson("Namespace already exists: foobar"))); } + + @Test + void testDeleteNamespaceNotLoggedIn() throws Exception { + mockMvc.perform(delete("/admin/namespace/test-namespace") + .with(csrf().asHeader())) + .andExpect(status().isForbidden()); + } + + @Test + void testDeleteNamespaceNotAdmin() throws Exception { + mockNormalUser(); + mockMvc.perform(delete("/admin/namespace/test-namespace") + .with(user("test_user")) + .with(csrf().asHeader())) + .andExpect(status().isForbidden()); + } + + @Test + void testDeleteNamespace() throws Exception { + mockAdminUser(); + var namespace = mockNamespace(); + Mockito.when(repositories.findExtensions(namespace)).thenReturn(Streamable.empty()); + + mockMvc.perform(delete("/admin/namespace/" + namespace.getName()) + .with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN")))) + .with(csrf().asHeader())) + .andExpect(status().isOk()) + .andExpect(content().json(successJson("Deleted namespace foobar"))); + } + + @Test + void testDeleteNamespaceHasMembers() throws Exception { + mockAdminUser(); + + var namespace = mockNamespace(1); + Mockito.when(repositories.findExtensions(namespace)).thenReturn(Streamable.empty()); + + mockMvc.perform(delete("/admin/namespace/" + namespace.getName()) + .with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN")))) + .with(csrf().asHeader())) + .andExpect(status().isOk()) + .andExpect(content().json(successJson("Deleted namespace foobar"))); + } + @Test + void testDeleteNamespaceNotExist() throws Exception { + mockAdminUser(); + mockMvc.perform(delete("/admin/namespace/" + UUID.randomUUID().toString()) + .with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN")))) + .with(csrf().asHeader())) + .andExpect(status().isNotFound()); + } + + @Test + void testDeleteNamespaceNotEmpty() throws Exception { + mockAdminUser(); + var extensionVersions = mockExtension(2, 0, 0); + mockMvc.perform(delete("/admin/namespace/" + extensionVersions.getFirst().getExtension().getNamespace().getName()) + .with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN")))) + .with(csrf().asHeader())) + .andExpect(status().isBadRequest()); + } + @Test void testGetUserPublishInfoNotLoggedIn() throws Exception { mockNamespace(); @@ -1256,14 +1355,37 @@ private UserData mockAdminUser() { } private Namespace mockNamespace() { + return mockNamespace(0); + } + + private Namespace mockNamespace(int numberOfMembers) { var namespace = new Namespace(); namespace.setName("foobar"); Mockito.when(repositories.findNamespace("foobar")) .thenReturn(namespace); Mockito.when(repositories.findActiveExtensions(namespace)) .thenReturn(Streamable.empty()); - Mockito.when(repositories.hasMemberships(namespace, NamespaceMembership.ROLE_OWNER)) + if (numberOfMembers == 0) { + Mockito.when(repositories.hasMemberships(namespace, NamespaceMembership.ROLE_OWNER)) .thenReturn(false); + Mockito.when(repositories.findMemberships(namespace)) + .thenReturn(Streamable.empty()); + } else { + Mockito.when(repositories.hasMemberships(namespace, NamespaceMembership.ROLE_OWNER)) + .thenReturn(true); + var memberships = new ArrayList(numberOfMembers); + var user = mockNormalUser(); + for (var i = 0; i < numberOfMembers; i++) { + var membership = new NamespaceMembership(); + membership.setNamespace(namespace); + membership.setRole(NamespaceMembership.ROLE_OWNER); + membership.setUser(user); + memberships.add(membership); + } + Mockito.when(repositories.findMemberships(namespace)) + .thenReturn(Streamable.of(memberships)); + } + return namespace; } @@ -1288,6 +1410,8 @@ private List mockExtension(int numberOfVersions, int numberOfB Mockito.when(entityManager.merge(extension)).thenReturn(extension); Mockito.when(repositories.findExtension("baz", "foobar")) .thenReturn(extension); + Mockito.when(repositories.findExtensions(namespace)) + .thenReturn(Streamable.of(extension)); var versions = new ArrayList(numberOfVersions); for (var i = 0; i < numberOfVersions; i++) { @@ -1350,6 +1474,7 @@ private List mockExtension(int numberOfVersions, int numberOfB .thenReturn(Streamable.empty()); Mockito.when(repositories.findDeprecatedExtensions(extension)) .thenReturn(Streamable.empty()); + return versions; } diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts index e29b88902..51a03b88f 100644 --- a/webui/src/extension-registry-service.ts +++ b/webui/src/extension-registry-service.ts @@ -504,6 +504,7 @@ export interface AdminService { deleteExtensions(abortController: AbortController, req: { namespace: string, extension: string, targetPlatformVersions?: object[] }): Promise> getNamespace(abortController: AbortController, name: string): Promise> createNamespace(abortController: AbortController, namespace: { name: string }): Promise> + deleteNamespace(abortController: AbortController, namespace: { name: string }): Promise> changeNamespace(abortController: AbortController, req: {oldNamespace: string, newNamespace: string, removeOldNamespace: boolean, mergeIfNewNamespaceAlreadyExists: boolean}): Promise> getPublisherInfo(abortController: AbortController, provider: string, login: string): Promise> revokePublisherContributions(abortController: AbortController, provider: string, login: string): Promise> @@ -601,6 +602,23 @@ export class AdminServiceImpl implements AdminService { }); } + async deleteNamespace(abortController: AbortController, namespace: { name: string }): Promise> { + const csrfResponse = await this.registry.getCsrfToken(abortController); + const headers: Record = { + 'Content-Type': 'application/json;charset=UTF-8' + }; + if (!isError(csrfResponse)) { + const csrfToken = csrfResponse as CsrfTokenJson; + headers[csrfToken.header] = csrfToken.value; + } + return sendRequest({ + abortController, + credentials: true, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'namespace', namespace.name]), + method: 'DELETE', + headers + }); + } async changeNamespace(abortController: AbortController, req: {oldNamespace: string, newNamespace: string, removeOldNamespace: boolean, mergeIfNewNamespaceAlreadyExists: boolean}): Promise> { const csrfResponse = await this.registry.getCsrfToken(abortController); const headers: Record = { diff --git a/webui/src/pages/admin-dashboard/namespace-delete-dialog.tsx b/webui/src/pages/admin-dashboard/namespace-delete-dialog.tsx new file mode 100644 index 000000000..7df42bfe2 --- /dev/null +++ b/webui/src/pages/admin-dashboard/namespace-delete-dialog.tsx @@ -0,0 +1,98 @@ +/******************************************************************************** + * Copyright (c) 2026 TypeFox and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + + import { FunctionComponent, useState, useContext, useEffect, useRef } from 'react'; + import { + Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle + } from '@mui/material'; + import { ButtonWithProgress } from '../../components/button-with-progress'; + import { Namespace, SuccessResult, isError } from '../../extension-registry-types'; + import { MainContext } from '../../context'; + import { InfoDialog } from '../../components/info-dialog'; + + export interface NamespaceDeleteDialogProps { + open: boolean; + onClose: () => void; + namespace: Namespace; + setLoadingState: (loading: boolean) => void; + } + + export const NamespaceDeleteDialog: FunctionComponent = props => { + const { open } = props; + const { service, handleError } = useContext(MainContext); + const [working, setWorking] = useState(false); + const [infoDialogIsOpen, setInfoDialogIsOpen] = useState(false); + const [infoDialogMessage, setInfoDialogMessage] = useState(''); + + const abortController = useRef(new AbortController()); + useEffect(() => { + return () => { + abortController.current.abort(); + }; + }, []); + + const onClose = () => { + props.onClose(); + }; + const onInfoDialogClose = () => { + onClose(); + setInfoDialogIsOpen(false); + }; + const handleDeleteNamespace = async () => { + try { + if (!props.namespace) { + return; + } + setWorking(true); + props.setLoadingState(true); + const name = props.namespace.name; + const result = await service.admin.deleteNamespace(abortController.current, { name }); + if (isError(result)) { + throw result; + } + + const successResult = result as SuccessResult; + props.setLoadingState(false); + setWorking(false); + setInfoDialogIsOpen(true); + setInfoDialogMessage(successResult.success); + } catch (err) { + props.setLoadingState(false); + setWorking(false); + handleError(err); + } + }; + + return <> + + Delete Namespace + + + Are you sure you'd like to delete the namespace? Only namespaces with no extensions can be deleted. + + + + + + Delete Namespace + + + + + ; + }; \ No newline at end of file diff --git a/webui/src/pages/user/user-settings-namespace-detail.tsx b/webui/src/pages/user/user-settings-namespace-detail.tsx index bbefce89c..168e8fa52 100644 --- a/webui/src/pages/user/user-settings-namespace-detail.tsx +++ b/webui/src/pages/user/user-settings-namespace-detail.tsx @@ -17,6 +17,7 @@ import { UserNamespaceExtensionListContainer } from './user-namespace-extension- import { AdminDashboardRoutes } from '../admin-dashboard/admin-routes'; import { Namespace, UserData } from '../../extension-registry-types'; import { NamespaceChangeDialog } from '../admin-dashboard/namespace-change-dialog'; +import { NamespaceDeleteDialog } from '../admin-dashboard/namespace-delete-dialog'; import { UserNamespaceMemberList } from './user-namespace-member-list'; import { UserNamespaceDetails } from './user-namespace-details'; @@ -61,6 +62,7 @@ const NamespaceHeader = styled(Box)(({ theme }: { theme: Theme }) => ({ export const NamespaceDetail: FunctionComponent = props => { const [changeDialogIsOpen, setChangeDialogIsOpen] = useState(false); + const [deleteDialogIsOpen, setDeleteDialogIsOpen] = useState(false); const { pathname } = useLocation(); const handleCloseChangeDialog = async () => { @@ -70,6 +72,12 @@ export const NamespaceDetail: FunctionComponent = props => setChangeDialogIsOpen(true); }; + const handleCloseDeleteDialog = async () => { + setDeleteDialogIsOpen(false); + }; + const handleOpenDeleteDialog = () => { + setDeleteDialogIsOpen(true); + }; const warningColor = props.theme === 'dark' ? '#fff' : '#151515'; return <> @@ -101,9 +109,18 @@ export const NamespaceDetail: FunctionComponent = props => {props.namespace.name} { pathname.startsWith(AdminDashboardRoutes.NAMESPACE_ADMIN) - ? + ? + + + + : null } @@ -137,6 +154,11 @@ export const NamespaceDetail: FunctionComponent = props => onClose={handleCloseChangeDialog} namespace={props.namespace} setLoadingState={props.setLoadingState} /> + ; }; From c93e143525b01b1ae8a99c96f9fb0c5455c35805 Mon Sep 17 00:00:00 2001 From: Martin Lowe Date: Mon, 20 Apr 2026 09:21:21 -0400 Subject: [PATCH 3/7] fix: Update mockNamespace to properly handle fake users Previous iteration added an optional number of members to stub in for memberships. This used the mock user test method, which replaced the admin user in the findLoggedInUser check. This broke the test that used the additional members, and needed to be accommodated. --- .../test/java/org/eclipse/openvsx/admin/AdminAPITest.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java index 23006b32d..bd595cb15 100644 --- a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java @@ -1374,7 +1374,10 @@ private Namespace mockNamespace(int numberOfMembers) { Mockito.when(repositories.hasMemberships(namespace, NamespaceMembership.ROLE_OWNER)) .thenReturn(true); var memberships = new ArrayList(numberOfMembers); - var user = mockNormalUser(); + + var user = new UserData(); + user.setLoginName(UUID.randomUUID().toString()); + user.setFullName("Test User"); for (var i = 0; i < numberOfMembers; i++) { var membership = new NamespaceMembership(); membership.setNamespace(namespace); From 12367fedfda2da6e08ebb9c4e18caa6a64d41fa8 Mon Sep 17 00:00:00 2001 From: Martin Lowe Date: Tue, 21 Apr 2026 13:25:16 -0400 Subject: [PATCH 4/7] chore: Update CHANGELOG for webui for namespace delete feat --- webui/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/webui/CHANGELOG.md b/webui/CHANGELOG.md index fae582014..253e27f37 100644 --- a/webui/CHANGELOG.md +++ b/webui/CHANGELOG.md @@ -8,6 +8,7 @@ This change log covers only the frontend library (webui) of Open VSX. - Refactor extension detail page to avoid unnecessary data reloads ([#1760](https://github.com/eclipse/openvsx/pull/1760)) - Fix refreshing of extension readme when switching versions ([#1760](https://github.com/eclipse/openvsx/pull/1760)) +- Add the ability to delete empty namespaces in the admin dashboard ([#1773](https://github.com/eclipse/openvsx/pull/1773)) ## [v0.20.0] (13/04/2026) From 91eae4b73f5dfd3717f5ede2c928a3ed9bac8186 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Wed, 22 Apr 2026 13:59:08 +0200 Subject: [PATCH 5/7] update cli instructions --- doc/development.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/development.md b/doc/development.md index 438299410..dc07679d8 100644 --- a/doc/development.md +++ b/doc/development.md @@ -144,6 +144,7 @@ To run the Open VSX registry in a development environment, you can use `docker c - yarn install - yarn prepare + - yarn build - cd server @@ -156,6 +157,7 @@ To run the Open VSX registry in a development environment, you can use `docker c - yarn build - yarn build:default - yarn start:default + - Go to localhost:3000 on browser and it should be up and running ### Optional: Deploy example extensions to your local registry From d5714356da82b0af9bf13052152345dcfa84334d Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Wed, 22 Apr 2026 15:27:28 +0200 Subject: [PATCH 6/7] also delete the namespace icon if it exists, improve 404 handling --- .../org/eclipse/openvsx/admin/AdminAPI.java | 38 +++++++++---------- .../eclipse/openvsx/admin/AdminService.java | 22 +++++++---- .../eclipse/openvsx/admin/AdminAPITest.java | 2 +- 3 files changed, 35 insertions(+), 27 deletions(-) diff --git a/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java b/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java index af3a8fd3e..1ea05f8a0 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java @@ -400,8 +400,26 @@ private String createAdminNamespaceUrl(NamespaceJson namespace) { return UrlUtil.createApiUrl(UrlUtil.getBaseUrl(), "admin", "namespace", namespace.getName()); } + @PostMapping( + path = "/admin/create-namespace", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity createNamespace(@RequestBody NamespaceJson namespace) { + try { + admins.checkAdminUser(); + var json = admins.createNamespace(namespace); + var url = createAdminNamespaceUrl(namespace); + return ResponseEntity.status(HttpStatus.CREATED) + .location(URI.create(url)) + .body(json); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(); + } + } + @DeleteMapping( - path = "/admin/namespace/{namespaceName}" + path = "/admin/namespace/{namespaceName}" ) @Operation(summary = "Delete a namespace") @ApiResponse( @@ -430,24 +448,6 @@ public ResponseEntity deleteNamespace(@PathVariable String namespace } } - @PostMapping( - path = "/admin/create-namespace", - consumes = MediaType.APPLICATION_JSON_VALUE, - produces = MediaType.APPLICATION_JSON_VALUE - ) - public ResponseEntity createNamespace(@RequestBody NamespaceJson namespace) { - try { - admins.checkAdminUser(); - var json = admins.createNamespace(namespace); - var url = createAdminNamespaceUrl(namespace); - return ResponseEntity.status(HttpStatus.CREATED) - .location(URI.create(url)) - .body(json); - } catch (ErrorResultException exc) { - return exc.toResponseEntity(); - } - } - @PostMapping( path = "/admin/change-namespace", consumes = MediaType.APPLICATION_JSON_VALUE, diff --git a/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java b/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java index b090c826a..6ca170c68 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java @@ -279,16 +279,15 @@ protected ResultJson deleteExtension(ExtensionVersion extVersion, UserData admin } @Transactional(rollbackOn = ErrorResultException.class) - public ResultJson deleteNamespace(String namespaceName, UserData admin) - throws ErrorResultException { - var namespaceEntity = repositories.findNamespace(namespaceName); - if (namespaceEntity == null) { - throw new ErrorResultException("Namespace not found: " + namespaceName, HttpStatus.NOT_FOUND); + public ResultJson deleteNamespace(String namespaceName, UserData admin) throws ErrorResultException { + var namespace = repositories.findNamespace(namespaceName); + if (namespace == null) { + throw new NotFoundException(); } - return deleteNamespace(namespaceEntity, admin); + return deleteNamespace(namespace, admin); } - protected ResultJson deleteNamespace(Namespace namespace, UserData admin) { + private ResultJson deleteNamespace(Namespace namespace, UserData admin) { var namespaceExtensions = repositories.findExtensions(namespace); if (!namespaceExtensions.isEmpty()) { throw new ErrorResultException("Cannot delete namespaces that contain extensions.", HttpStatus.BAD_REQUEST); @@ -299,7 +298,16 @@ protected ResultJson deleteNamespace(Namespace namespace, UserData admin) { entityManager.remove(membership); } + if (namespace.getLogoStorageType() != null) { + try { + storageUtil.removeNamespaceLogo(namespace); + } catch (RuntimeException exc) { + throw new ErrorResultException("Failed to delete namespace icon: " + exc.getMessage()); + } + } + entityManager.remove(namespace); + // Clear cache for the namespace cache.evictNamespaceDetails(namespace); diff --git a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java index bd595cb15..2c8b73083 100644 --- a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java @@ -640,7 +640,7 @@ void testDeleteNamespaceHasMembers() throws Exception { @Test void testDeleteNamespaceNotExist() throws Exception { mockAdminUser(); - mockMvc.perform(delete("/admin/namespace/" + UUID.randomUUID().toString()) + mockMvc.perform(delete("/admin/namespace/" + UUID.randomUUID()) .with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN")))) .with(csrf().asHeader())) .andExpect(status().isNotFound()); From ca3f4c690c548addbd86a7d3c8c82a26dc40f382 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Wed, 22 Apr 2026 15:27:49 +0200 Subject: [PATCH 7/7] improve ui handling --- webui/CHANGELOG.md | 5 +- .../pages/admin-dashboard/namespace-admin.tsx | 5 + .../namespace-change-dialog.tsx | 101 ++++++++++-------- .../namespace-delete-dialog.tsx | 97 ++++++++--------- webui/src/pages/user/user-setting-tabs.tsx | 5 +- .../user/user-settings-namespace-detail.tsx | 27 +++-- .../pages/user/user-settings-namespaces.tsx | 2 +- 7 files changed, 131 insertions(+), 111 deletions(-) diff --git a/webui/CHANGELOG.md b/webui/CHANGELOG.md index 253e27f37..fa3b20c24 100644 --- a/webui/CHANGELOG.md +++ b/webui/CHANGELOG.md @@ -4,11 +4,14 @@ This change log covers only the frontend library (webui) of Open VSX. ## [next] (unreleased) +### Added + +- Add support to delete empty namespaces in the admin dashboard ([#1773](https://github.com/eclipse/openvsx/pull/1773)) + ### Fixed - Refactor extension detail page to avoid unnecessary data reloads ([#1760](https://github.com/eclipse/openvsx/pull/1760)) - Fix refreshing of extension readme when switching versions ([#1760](https://github.com/eclipse/openvsx/pull/1760)) -- Add the ability to delete empty namespaces in the admin dashboard ([#1773](https://github.com/eclipse/openvsx/pull/1773)) ## [v0.20.0] (13/04/2026) diff --git a/webui/src/pages/admin-dashboard/namespace-admin.tsx b/webui/src/pages/admin-dashboard/namespace-admin.tsx index 76a74a080..d6facaaee 100644 --- a/webui/src/pages/admin-dashboard/namespace-admin.tsx +++ b/webui/src/pages/admin-dashboard/namespace-admin.tsx @@ -31,6 +31,10 @@ export const NamespaceAdmin: FunctionComponent = props => { }; }, []); + const handleDeleteNamespace = () => { + setCurrentNamespace(undefined); + }; + const fetchNamespace = async (namespaceName: string) => { if (!namespaceName) { setCurrentNamespace(undefined); @@ -82,6 +86,7 @@ export const NamespaceAdmin: FunctionComponent = props => { listContainer = true} fixSelf={false} diff --git a/webui/src/pages/admin-dashboard/namespace-change-dialog.tsx b/webui/src/pages/admin-dashboard/namespace-change-dialog.tsx index 63f6453bb..652ff5637 100644 --- a/webui/src/pages/admin-dashboard/namespace-change-dialog.tsx +++ b/webui/src/pages/admin-dashboard/namespace-change-dialog.tsx @@ -8,38 +8,38 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ - import { ChangeEvent, FunctionComponent, useState, useContext, useEffect, useRef } from 'react'; - import { - Button, Checkbox, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, FormControlLabel, TextField - } from '@mui/material'; - import { ButtonWithProgress } from '../../components/button-with-progress'; - import { Namespace, SuccessResult, isError } from '../../extension-registry-types'; - import { MainContext } from '../../context'; - import { InfoDialog } from '../../components/info-dialog'; +import { ChangeEvent, FunctionComponent, useState, useContext, useEffect, useRef } from 'react'; +import { + Button, Checkbox, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, FormControlLabel, TextField +} from '@mui/material'; +import { ButtonWithProgress } from '../../components/button-with-progress'; +import { Namespace, SuccessResult, isError } from '../../extension-registry-types'; +import { MainContext } from '../../context'; +import { InfoDialog } from '../../components/info-dialog'; - export interface NamespaceChangeDialogProps { - open: boolean; - onClose: () => void; - namespace: Namespace; - setLoadingState: (loading: boolean) => void; - } +export interface NamespaceChangeDialogProps { + open: boolean; + onClose: () => void; + namespace: Namespace; + setLoadingState: (loading: boolean) => void; +} - export const NamespaceChangeDialog: FunctionComponent = props => { - const { open } = props; - const { service, handleError } = useContext(MainContext); - const [working, setWorking] = useState(false); - const [newNamespace, setNewNamespace] = useState(''); - const [removeOldNamespace, setRemoveOldNamespace] = useState(false); - const [mergeIfNewNamespaceAlreadyExists, setMergeIfNewNamespaceAlreadyExists] = useState(false); - const [infoDialogIsOpen, setInfoDialogIsOpen] = useState(false); - const [infoDialogMessage, setInfoDialogMessage] = useState(''); +export const NamespaceChangeDialog: FunctionComponent = props => { + const { open } = props; + const { service, handleError } = useContext(MainContext); + const [working, setWorking] = useState(false); + const [newNamespace, setNewNamespace] = useState(''); + const [removeOldNamespace, setRemoveOldNamespace] = useState(false); + const [mergeIfNewNamespaceAlreadyExists, setMergeIfNewNamespaceAlreadyExists] = useState(false); + const [infoDialogIsOpen, setInfoDialogIsOpen] = useState(false); + const [infoDialogMessage, setInfoDialogMessage] = useState(''); - const abortController = useRef(new AbortController()); - useEffect(() => { - return () => { - abortController.current.abort(); - }; - }, []); + const abortController = useRef(new AbortController()); + useEffect(() => { + return () => { + abortController.current.abort(); + }; + }, []); useEffect(() => { if (open) { @@ -70,7 +70,12 @@ setWorking(true); props.setLoadingState(true); const oldNamespace = props.namespace.name; - const result = await service.admin.changeNamespace(abortController.current, { oldNamespace, newNamespace, removeOldNamespace, mergeIfNewNamespaceAlreadyExists }); + const result = await service.admin.changeNamespace(abortController.current, { + oldNamespace, + newNamespace, + removeOldNamespace, + mergeIfNewNamespaceAlreadyExists + }); if (isError(result)) { throw result; } @@ -87,12 +92,12 @@ } }; - return <> + return <> Change Namespace - Enter the new Namespace name. + Enter the new Namespace name. } - label={`Remove '${props.namespace.name}' namespace after namespace change`} /> + control={} + label={`Remove '${props.namespace.name}' namespace after namespace change`}/> } - label='Merge namespaces if new namespace already exists' /> - - - + onClick={handleChangeNamespace}> Change Namespace - - - - ; - }; \ No newline at end of file + + + + ; +}; \ No newline at end of file diff --git a/webui/src/pages/admin-dashboard/namespace-delete-dialog.tsx b/webui/src/pages/admin-dashboard/namespace-delete-dialog.tsx index 7df42bfe2..57985714f 100644 --- a/webui/src/pages/admin-dashboard/namespace-delete-dialog.tsx +++ b/webui/src/pages/admin-dashboard/namespace-delete-dialog.tsx @@ -1,50 +1,44 @@ -/******************************************************************************** - * Copyright (c) 2026 TypeFox and others +/****************************************************************************** + * 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 v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. + * 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, useState, useContext, useEffect, useRef } from 'react'; - import { - Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle - } from '@mui/material'; - import { ButtonWithProgress } from '../../components/button-with-progress'; - import { Namespace, SuccessResult, isError } from '../../extension-registry-types'; - import { MainContext } from '../../context'; - import { InfoDialog } from '../../components/info-dialog'; +import { FunctionComponent, useState, useContext, useEffect, useRef } from 'react'; +import { + Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle +} from '@mui/material'; +import { ButtonWithProgress } from '../../components/button-with-progress'; +import { Namespace, isError } from '../../extension-registry-types'; +import { MainContext } from '../../context'; - export interface NamespaceDeleteDialogProps { - open: boolean; - onClose: () => void; - namespace: Namespace; - setLoadingState: (loading: boolean) => void; - } +export interface NamespaceDeleteDialogProps { + open: boolean; + onClose: () => void; + onDelete: () => void; + namespace: Namespace; + setLoadingState: (loading: boolean) => void; +} - export const NamespaceDeleteDialog: FunctionComponent = props => { - const { open } = props; - const { service, handleError } = useContext(MainContext); - const [working, setWorking] = useState(false); - const [infoDialogIsOpen, setInfoDialogIsOpen] = useState(false); - const [infoDialogMessage, setInfoDialogMessage] = useState(''); +export const NamespaceDeleteDialog: FunctionComponent = props => { + const { open, onClose, onDelete, namespace } = props; + const { service, handleError } = useContext(MainContext); + const [working, setWorking] = useState(false); - const abortController = useRef(new AbortController()); - useEffect(() => { - return () => { - abortController.current.abort(); - }; - }, []); + const abortController = useRef(new AbortController()); + useEffect(() => { + return () => { + abortController.current.abort(); + }; + }, []); - const onClose = () => { - props.onClose(); - }; - const onInfoDialogClose = () => { - onClose(); - setInfoDialogIsOpen(false); - }; const handleDeleteNamespace = async () => { try { if (!props.namespace) { @@ -58,11 +52,9 @@ throw result; } - const successResult = result as SuccessResult; props.setLoadingState(false); setWorking(false); - setInfoDialogIsOpen(true); - setInfoDialogMessage(successResult.success); + onDelete(); } catch (err) { props.setLoadingState(false); setWorking(false); @@ -70,29 +62,28 @@ } }; - return <> + return <> Delete Namespace - Are you sure you'd like to delete the namespace? Only namespaces with no extensions can be deleted. + Are you sure you want to delete the namespace {namespace.name}? - - - + onClick={handleDeleteNamespace}> Delete Namespace - - - - ; - }; \ No newline at end of file + + + ; +}; diff --git a/webui/src/pages/user/user-setting-tabs.tsx b/webui/src/pages/user/user-setting-tabs.tsx index da8f27ed2..f2c2979ca 100644 --- a/webui/src/pages/user/user-setting-tabs.tsx +++ b/webui/src/pages/user/user-setting-tabs.tsx @@ -20,6 +20,7 @@ export const UserSettingTabs = (): ReactElement => { const isATablet = useMediaQuery(theme.breakpoints.down('md')); const isAMobile = useMediaQuery(theme.breakpoints.down('sm')); const { tab } = useParams(); + const navigate = useNavigate(); const handleChange = (event: ChangeEvent, newTab: string) => { @@ -32,10 +33,10 @@ export const UserSettingTabs = (): ReactElement => { return ( diff --git a/webui/src/pages/user/user-settings-namespace-detail.tsx b/webui/src/pages/user/user-settings-namespace-detail.tsx index 168e8fa52..312b7dceb 100644 --- a/webui/src/pages/user/user-settings-namespace-detail.tsx +++ b/webui/src/pages/user/user-settings-namespace-detail.tsx @@ -75,6 +75,13 @@ export const NamespaceDetail: FunctionComponent = props => const handleCloseDeleteDialog = async () => { setDeleteDialogIsOpen(false); }; + const handleDeletedNamespace = async () => { + setDeleteDialogIsOpen(false); + if (props.onDelete !== undefined) { + props.onDelete(); + } + }; + const handleOpenDeleteDialog = () => { setDeleteDialogIsOpen(true); }; @@ -109,17 +116,19 @@ export const NamespaceDetail: FunctionComponent = props => {props.namespace.name} { pathname.startsWith(AdminDashboardRoutes.NAMESPACE_ADMIN) - ? + ? - + { Object.keys(props.namespace.extensions).length === 0 && + + } : null } @@ -157,6 +166,7 @@ export const NamespaceDetail: FunctionComponent = props => ; @@ -169,4 +179,5 @@ export interface NamespaceDetailProps { setLoadingState: (loading: boolean) => void; namespaceAccessUrl?: string; theme?: string; -} \ No newline at end of file + onDelete?: () => void; +} diff --git a/webui/src/pages/user/user-settings-namespaces.tsx b/webui/src/pages/user/user-settings-namespaces.tsx index e9181fc8d..6f04ab75c 100644 --- a/webui/src/pages/user/user-settings-namespaces.tsx +++ b/webui/src/pages/user/user-settings-namespaces.tsx @@ -117,7 +117,7 @@ export const UserSettingsNamespaces: FunctionComponent = () => { filterUsers={(foundUser: UserData) => foundUser.provider !== user?.provider || foundUser.loginName !== user?.loginName} fixSelf={true} namespaceAccessUrl={namespaceAccessUrl} - theme={pageSettings.themeType}/> + theme={pageSettings.themeType} /> ; } else if (!loading) { namespaceContainer = No namespaces available. Read here about claiming namespaces.;