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..dc07679d8 100644 --- a/doc/development.md +++ b/doc/development.md @@ -143,6 +143,7 @@ To run the Open VSX registry in a development environment, you can use `docker c - cd cli - 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 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..1ea05f8a0 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; @@ -412,6 +418,36 @@ public ResponseEntity createNamespace(@RequestBody NamespaceJson nam } } + @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/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 20e2e99e2..6ca170c68 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,44 @@ protected ResultJson deleteExtension(ExtensionVersion extVersion, UserData admin return result; } + @Transactional(rollbackOn = ErrorResultException.class) + public ResultJson deleteNamespace(String namespaceName, UserData admin) throws ErrorResultException { + var namespace = repositories.findNamespace(namespaceName); + if (namespace == null) { + throw new NotFoundException(); + } + return deleteNamespace(namespace, 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); + } + + var memberships = repositories.findMemberships(namespace); + for (var membership : memberships) { + 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); + + 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 6c64b1a2a..ffdd05740 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.publish.PublishingConfig; @@ -37,8 +77,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; @@ -47,7 +92,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; @@ -63,18 +107,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 @@ -558,6 +595,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()) + .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(); @@ -1257,14 +1356,40 @@ 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 = 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); + membership.setRole(NamespaceMembership.ROLE_OWNER); + membership.setUser(user); + memberships.add(membership); + } + Mockito.when(repositories.findMemberships(namespace)) + .thenReturn(Streamable.of(memberships)); + } + return namespace; } @@ -1289,6 +1414,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++) { @@ -1351,6 +1478,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/CHANGELOG.md b/webui/CHANGELOG.md index 56d47e2fb..686c76bd3 100644 --- a/webui/CHANGELOG.md +++ b/webui/CHANGELOG.md @@ -7,6 +7,7 @@ This change log covers only the frontend library (webui) of Open VSX. ### Added - Persist open/closed state for the admin dashboard side-panel ([#1782](https://github.com/eclipse-openvsx/openvsx/pull/1782)) +- Add support to delete empty namespaces in the admin dashboard ([#1773](https://github.com/eclipse/openvsx/pull/1773)) ### Changed 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-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 new file mode 100644 index 000000000..57985714f --- /dev/null +++ b/webui/src/pages/admin-dashboard/namespace-delete-dialog.tsx @@ -0,0 +1,89 @@ +/****************************************************************************** + * 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, 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; + onDelete: () => void; + namespace: Namespace; + setLoadingState: (loading: boolean) => void; +} + +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 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; + } + + props.setLoadingState(false); + setWorking(false); + onDelete(); + } catch (err) { + props.setLoadingState(false); + setWorking(false); + handleError(err); + } + }; + + return <> + + Delete Namespace + + + Are you sure you want to delete the namespace {namespace.name}? + + + + + + Delete Namespace + + + + ; +}; 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 43c888410..0af2be438 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-dashboard-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,19 @@ export const NamespaceDetail: FunctionComponent = props => setChangeDialogIsOpen(true); }; + const handleCloseDeleteDialog = async () => { + setDeleteDialogIsOpen(false); + }; + const handleDeletedNamespace = async () => { + setDeleteDialogIsOpen(false); + if (props.onDelete !== undefined) { + props.onDelete(); + } + }; + + const handleOpenDeleteDialog = () => { + setDeleteDialogIsOpen(true); + }; const warningColor = props.theme === 'dark' ? '#fff' : '#151515'; return <> @@ -101,9 +116,20 @@ export const NamespaceDetail: FunctionComponent = props => {props.namespace.name} { pathname.startsWith(AdminDashboardRoutes.NAMESPACE_ADMIN) - ? + ? + + + { Object.keys(props.namespace.extensions).length === 0 && + + } + : null } @@ -137,6 +163,12 @@ export const NamespaceDetail: FunctionComponent = props => onClose={handleCloseChangeDialog} namespace={props.namespace} setLoadingState={props.setLoadingState} /> + ; }; @@ -147,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.;