From 59c0b152f5b3e342bdedaafebfaa41bbe4fd69e1 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Tue, 23 Jun 2020 16:07:38 +0200 Subject: [PATCH 01/20] add rest endpoint for renaming repository name and namespace --- .../java/sonia/scm/repository/Repository.java | 2 +- .../api/v2/resources/RepositoryRenameDto.java | 39 +++++++++ .../api/v2/resources/RepositoryResource.java | 85 ++++++++++++++++--- .../RepositoryToRepositoryDtoMapper.java | 12 ++- .../scm/api/v2/resources/ResourceLinks.java | 4 + .../META-INF/scm/repository-permissions.xml | 1 + .../resources/RepositoryRootResourceTest.java | 66 ++++++++++++++ .../api/v2/resources/RepositoryTestBase.java | 6 +- .../RepositoryToRepositoryDtoMapperTest.java | 21 +++++ .../sonia/scm/api/v2/rename-repo.json | 4 + 10 files changed, 226 insertions(+), 14 deletions(-) create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryRenameDto.java create mode 100644 scm-webapp/src/test/resources/sonia/scm/api/v2/rename-repo.json diff --git a/scm-core/src/main/java/sonia/scm/repository/Repository.java b/scm-core/src/main/java/sonia/scm/repository/Repository.java index b1de749cdf..bd02021ed6 100644 --- a/scm-core/src/main/java/sonia/scm/repository/Repository.java +++ b/scm-core/src/main/java/sonia/scm/repository/Repository.java @@ -53,7 +53,7 @@ import java.util.Set; */ @StaticPermissions( value = "repository", - permissions = {"read", "modify", "delete", "healthCheck", "pull", "push", "permissionRead", "permissionWrite"}, + permissions = {"read", "modify", "delete", "rename", "healthCheck", "pull", "push", "permissionRead", "permissionWrite"}, custom = true, customGlobal = true ) @XmlAccessorType(XmlAccessType.FIELD) diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryRenameDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryRenameDto.java new file mode 100644 index 0000000000..67848db3fe --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryRenameDto.java @@ -0,0 +1,39 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.api.v2.resources; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import sonia.scm.util.ValidationUtil; + +import javax.validation.constraints.Pattern; + +@Getter +@NoArgsConstructor +public class RepositoryRenameDto { + @Pattern(regexp = ValidationUtil.REGEX_REPOSITORYNAME) + private String name; + private String namespace; +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java index 2ac4f0674f..a658973257 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java @@ -24,21 +24,24 @@ package sonia.scm.api.v2.resources; +import com.google.common.base.Strings; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import sonia.scm.config.ScmConfiguration; import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryManager; +import sonia.scm.repository.RepositoryPermissions; import sonia.scm.web.VndMediaType; import javax.inject.Inject; -import javax.inject.Provider; import javax.validation.Valid; import javax.ws.rs.Consumes; import javax.ws.rs.DELETE; import javax.ws.rs.GET; +import javax.ws.rs.POST; import javax.ws.rs.PUT; import javax.ws.rs.Path; import javax.ws.rs.PathParam; @@ -59,18 +62,20 @@ public class RepositoryResource { private final RepositoryManager manager; private final SingleResourceManagerAdapter adapter; private final RepositoryBasedResourceProvider resourceProvider; + private final ScmConfiguration scmConfiguration; @Inject public RepositoryResource( RepositoryToRepositoryDtoMapper repositoryToDtoMapper, RepositoryDtoToRepositoryMapper dtoToRepositoryMapper, RepositoryManager manager, - RepositoryBasedResourceProvider resourceProvider - ) { + RepositoryBasedResourceProvider resourceProvider, + ScmConfiguration scmConfiguration) { this.dtoToRepositoryMapper = dtoToRepositoryMapper; this.manager = manager; this.repositoryToDtoMapper = repositoryToDtoMapper; this.adapter = new SingleResourceManagerAdapter<>(manager, Repository.class); this.resourceProvider = resourceProvider; + this.scmConfiguration = scmConfiguration; } /** @@ -79,8 +84,7 @@ public class RepositoryResource { * Note: This method requires "repository" privilege. * * @param namespace the namespace of the repository - * @param name the name of the repository - * + * @param name the name of the repository */ @GET @Path("") @@ -118,7 +122,7 @@ public class RepositoryResource { schema = @Schema(implementation = ErrorDto.class) ) ) - public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name){ + public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name) { return adapter.get(loadBy(namespace, name), repositoryToDtoMapper::map); } @@ -128,8 +132,7 @@ public class RepositoryResource { * Note: This method requires "repository" privilege. * * @param namespace the namespace of the repository to delete - * @param name the name of the repository to delete - * + * @param name the name of the repository to delete */ @DELETE @Path("") @@ -147,8 +150,8 @@ public class RepositoryResource { * * Note: This method requires "repository" privilege. * - * @param namespace the namespace of the repository to be modified - * @param name the name of the repository to be modified + * @param namespace the namespace of the repository to be modified + * @param name the name of the repository to be modified * @param repository repository object to modify */ @PUT @@ -176,6 +179,68 @@ public class RepositoryResource { ); } + /** + * Renames the given repository. + * + * Note: This method requires "repository" privilege. + * + * @param namespace the namespace of the repository to be modified + * @param name the name of the repository to be modified + * @param renameDto renameDto object to modify + */ + @POST + @Path("rename") + @Consumes(VndMediaType.REPOSITORY) + @Operation(summary = "Rename repository", description = "Renames the repository for the given namespace and name.", tags = "Repository") + @ApiResponse(responseCode = "204", description = "update success") + @ApiResponse(responseCode = "400", description = "invalid body, e.g. illegal change of namespace or name") + @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") + @ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"repository:renameDto\" privilege") + @ApiResponse( + responseCode = "404", + description = "not found, no repository with the specified namespace and name available", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + )) + @ApiResponse(responseCode = "500", description = "internal server error") + public Response rename(@PathParam("namespace") String namespace, @PathParam("name") String name, @Valid RepositoryRenameDto renameDto) { + Repository repo = loadBy(namespace, name).get(); + + if (isRenameForbidden(repo)) { + return Response.status(403).build(); + } + + if (hasNamespaceOrNameNotChanged(repo, renameDto)) { + return Response.status(400).build(); + } + + if (!Strings.isNullOrEmpty(renameDto.getName())) { + repo.setName(renameDto.getName()); + } + if (!Strings.isNullOrEmpty(renameDto.getNamespace())) { + repo.setNamespace(renameDto.getNamespace()); + } + + return adapter.update( + loadBy(namespace, name), + existing -> repo, + changed -> true, + r -> r.getNamespaceAndName().logString() + ); + } + + private boolean hasNamespaceOrNameNotChanged(Repository repo, @Valid RepositoryRenameDto renameDto) { + return repo.getName().equals(renameDto.getName()) + && repo.getNamespace().equals(renameDto.getNamespace()); + } + + private boolean isRenameForbidden(Repository repo) { + return !scmConfiguration.getNamespaceStrategy().equals("CustomNamespaceStrategy") + || !RepositoryPermissions.rename(repo).isPermitted(); + } + + private Repository processUpdate(RepositoryDto repositoryDto, Repository existing) { Repository changedRepository = dtoToRepositoryMapper.map(repositoryDto, existing.getId()); changedRepository.setPermissions(existing.getPermissions()); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java index 8dd3e8fb1b..3de24d64a7 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java @@ -24,12 +24,12 @@ package sonia.scm.api.v2.resources; -import com.google.inject.Inject; import de.otto.edison.hal.Embedded; import de.otto.edison.hal.Link; import de.otto.edison.hal.Links; import org.mapstruct.Mapper; import org.mapstruct.ObjectFactory; +import sonia.scm.config.ScmConfiguration; import sonia.scm.repository.Feature; import sonia.scm.repository.HealthCheckFailure; import sonia.scm.repository.Repository; @@ -41,6 +41,7 @@ import sonia.scm.repository.api.ScmProtocol; import sonia.scm.web.EdisonHalAppender; import sonia.scm.web.api.RepositoryToHalMapper; +import javax.inject.Inject; import java.util.List; import static de.otto.edison.hal.Embedded.embeddedBuilder; @@ -56,6 +57,8 @@ public abstract class RepositoryToRepositoryDtoMapper extends BaseMapperread modify delete + rename pull push permissionRead diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java index bcc1d2bc3f..81f712b728 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java @@ -35,11 +35,13 @@ import org.jboss.resteasy.mock.MockHttpResponse; import org.junit.Before; import org.junit.Rule; import org.junit.Test; +import org.junit.jupiter.api.Nested; import org.mockito.ArgumentCaptor; import org.mockito.Captor; import org.mockito.InjectMocks; import org.mockito.Mock; import sonia.scm.PageResult; +import sonia.scm.config.ScmConfiguration; import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryInitializer; @@ -62,6 +64,7 @@ import static java.util.Collections.singletonList; import static java.util.stream.Stream.of; import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST; import static javax.servlet.http.HttpServletResponse.SC_CONFLICT; +import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN; import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND; import static javax.servlet.http.HttpServletResponse.SC_NO_CONTENT; import static javax.servlet.http.HttpServletResponse.SC_OK; @@ -103,6 +106,8 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { private ScmPathInfo uriInfo; @Mock private RepositoryInitializer repositoryInitializer; + @Mock + private ScmConfiguration scmConfiguration; @Captor private ArgumentCaptor> filterCaptor; @@ -121,11 +126,13 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { super.repositoryToDtoMapper = repositoryToDtoMapper; super.dtoToRepositoryMapper = dtoToRepositoryMapper; super.manager = repositoryManager; + super.scmConfiguration = scmConfiguration; RepositoryCollectionToDtoMapper repositoryCollectionToDtoMapper = new RepositoryCollectionToDtoMapper(repositoryToDtoMapper, resourceLinks); super.repositoryCollectionResource = new RepositoryCollectionResource(repositoryManager, repositoryCollectionToDtoMapper, dtoToRepositoryMapper, resourceLinks, repositoryInitializer); dispatcher.addSingletonResource(getRepositoryRootResource()); when(serviceFactory.create(any(Repository.class))).thenReturn(service); when(scmPathInfoStore.get()).thenReturn(uriInfo); + when(scmConfiguration.getNamespaceStrategy()).thenReturn("CustomNamespaceStrategy"); when(uriInfo.getApiRestUri()).thenReturn(URI.create("/x/y")); SimplePrincipalCollection trillian = new SimplePrincipalCollection("trillian", REALM); trillian.add(new User("trillian"), REALM); @@ -372,6 +379,65 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { assertTrue(response.getContentAsString().contains("\"protocol\":[{\"href\":\"http://\",\"name\":\"http\"},{\"href\":\"ssh://\",\"name\":\"ssh\"}]")); } + @Test + public void shouldNotRenameRepositoryIfNamespaceStrategyIsNotCustom() throws Exception { + mockRepository("space", "repo"); + when(scmConfiguration.getNamespaceStrategy()).thenReturn("UsernameNamespaceStrategy"); + + URL url = Resources.getResource("sonia/scm/api/v2/rename-repo.json"); + byte[] repository = Resources.toByteArray(url); + + MockHttpRequest request = MockHttpRequest + .post("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/rename") + .contentType(VndMediaType.REPOSITORY) + .content(repository); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(SC_FORBIDDEN, response.getStatus()); + } + + @Test + public void shouldNotRenameRepositoryIfNamespaceAndNameDidNotChanged() throws Exception { + mockRepository("space", "x"); + when(scmConfiguration.getNamespaceStrategy()).thenReturn("CustomNamespaceStrategy"); + + URL url = Resources.getResource("sonia/scm/api/v2/rename-repo.json"); + byte[] repository = Resources.toByteArray(url); + + MockHttpRequest request = MockHttpRequest + .post("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/x/rename") + .contentType(VndMediaType.REPOSITORY) + .content(repository); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(SC_BAD_REQUEST, response.getStatus()); + } + + @Test + public void shouldRenameRepository() throws Exception { + mockRepository("space", "repo"); + when(scmConfiguration.getNamespaceStrategy()).thenReturn("CustomNamespaceStrategy"); + + URL url = Resources.getResource("sonia/scm/api/v2/rename-repo.json"); + byte[] repository = Resources.toByteArray(url); + + MockHttpRequest request = MockHttpRequest + .post("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/rename") + .contentType(VndMediaType.REPOSITORY) + .content(repository); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(SC_NO_CONTENT, response.getStatus()); + verify(repositoryManager).modify(any(Repository.class)); + } + + private PageResult createSingletonPageResult(Repository repository) { return new PageResult<>(singletonList(repository), 0); } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTestBase.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTestBase.java index bafb92fd62..cd86974c10 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTestBase.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTestBase.java @@ -24,6 +24,7 @@ package sonia.scm.api.v2.resources; +import sonia.scm.config.ScmConfiguration; import sonia.scm.repository.RepositoryManager; import static com.google.inject.util.Providers.of; @@ -46,6 +47,7 @@ abstract class RepositoryTestBase { IncomingRootResource incomingRootResource; RepositoryCollectionResource repositoryCollectionResource; AnnotateResource annotateResource; + ScmConfiguration scmConfiguration; RepositoryRootResource getRepositoryRootResource() { @@ -66,8 +68,8 @@ abstract class RepositoryTestBase { repositoryToDtoMapper, dtoToRepositoryMapper, manager, - repositoryBasedResourceProvider - )), + repositoryBasedResourceProvider, + scmConfiguration)), of(repositoryCollectionResource)); } } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapperTest.java index 53eb275d64..f1ffb2eda0 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapperTest.java @@ -33,6 +33,7 @@ import org.junit.Rule; import org.junit.Test; import org.mockito.InjectMocks; import org.mockito.Mock; +import sonia.scm.config.ScmConfiguration; import sonia.scm.repository.HealthCheckFailure; import sonia.scm.repository.Repository; import sonia.scm.repository.api.Command; @@ -72,6 +73,8 @@ public class RepositoryToRepositoryDtoMapperTest { private ScmPathInfoStore scmPathInfoStore; @Mock private ScmPathInfo uriInfo; + @Mock + private ScmConfiguration configuration; @InjectMocks private RepositoryToRepositoryDtoMapperImpl mapper; @@ -83,6 +86,7 @@ public class RepositoryToRepositoryDtoMapperTest { when(repositoryService.isSupported(any(Command.class))).thenReturn(true); when(repositoryService.getSupportedProtocols()).thenReturn(of()); when(scmPathInfoStore.get()).thenReturn(uriInfo); + when(configuration.getNamespaceStrategy()).thenReturn("CustomNamespaceStrategy"); when(uriInfo.getApiRestUri()).thenReturn(URI.create("/x/y")); } @@ -129,6 +133,23 @@ public class RepositoryToRepositoryDtoMapperTest { dto.getLinks().getLinkBy("update").get().getHref()); } + @Test + public void shouldCreateRenameLink() { + when(configuration.getNamespaceStrategy()).thenReturn("test"); + RepositoryDto dto = mapper.map(createTestRepository()); + assertEquals( + "http://example.com/base/v2/repositories/testspace/test/rename", + dto.getLinks().getLinkBy("rename").get().getHref()); + } + + @Test + public void shouldCreateRenameWithNamespaceLink() { + RepositoryDto dto = mapper.map(createTestRepository()); + assertEquals( + "http://example.com/base/v2/repositories/testspace/test/rename", + dto.getLinks().getLinkBy("renameWithNamespace").get().getHref()); + } + @Test public void shouldMapHealthCheck() { RepositoryDto dto = mapper.map(createTestRepository()); diff --git a/scm-webapp/src/test/resources/sonia/scm/api/v2/rename-repo.json b/scm-webapp/src/test/resources/sonia/scm/api/v2/rename-repo.json new file mode 100644 index 0000000000..bb6fefb14a --- /dev/null +++ b/scm-webapp/src/test/resources/sonia/scm/api/v2/rename-repo.json @@ -0,0 +1,4 @@ +{ + "name": "x", + "namespace": "space" +} From e32130cd0b3e7e45d9b2ec17ccc1c859fc1fde85 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Wed, 24 Jun 2020 10:39:00 +0200 Subject: [PATCH 02/20] create Dangerzone --- scm-ui/ui-webapp/public/locales/de/repos.json | 21 ++- scm-ui/ui-webapp/public/locales/en/repos.json | 21 ++- .../src/repos/containers/DangerZone.tsx | 74 ++++++++++ .../src/repos/containers/DeleteRepo.tsx | 33 +++-- .../src/repos/containers/EditRepo.tsx | 10 +- .../src/repos/containers/RenameRepository.tsx | 129 ++++++++++++++++++ 6 files changed, 267 insertions(+), 21 deletions(-) create mode 100644 scm-ui/ui-webapp/src/repos/containers/DangerZone.tsx create mode 100644 scm-ui/ui-webapp/src/repos/containers/RenameRepository.tsx diff --git a/scm-ui/ui-webapp/public/locales/de/repos.json b/scm-ui/ui-webapp/public/locales/de/repos.json index 882f63a7e6..e6906f8800 100644 --- a/scm-ui/ui-webapp/public/locales/de/repos.json +++ b/scm-ui/ui-webapp/public/locales/de/repos.json @@ -110,7 +110,8 @@ "repositoryForm": { "subtitle": "Repository bearbeiten", "submit": "Speichern", - "initializeRepository": "Repository initiieren" + "initializeRepository": "Repository initiieren", + "dangerZone": "Gefahrenzone" }, "sources": { "file-tree": { @@ -193,6 +194,8 @@ }, "deleteRepo": { "button": "Repository löschen", + "subtitle": "Löscht dieses Repository", + "description": "Diese Aktion kann nicht rückgangig gemacht werden.", "confirmAlert": { "title": "Repository löschen", "message": "Soll das Repository wirklich gelöscht werden?", @@ -200,6 +203,22 @@ "cancel": "Nein" } }, + "renameRepo": { + "button": "Repository umbenennen", + "subtitle": "Benennt dieses Repository um", + "description": "Es werden keine Weiterleitung auf den neuen Namen eingerichtet.", + "modal": { + "title": "Repository umbenennen", + "label": { + "repoName": "Repository Name", + "repoNamespace": "Repository Namespace" + }, + "button": { + "rename": "Umbenennen", + "cancel": "Abbrechen" + } + } + }, "diff": { "sideBySide": "Zur zweispaltigen Ansicht wechseln", "combined": "Zur kombinierten Ansicht wechseln", diff --git a/scm-ui/ui-webapp/public/locales/en/repos.json b/scm-ui/ui-webapp/public/locales/en/repos.json index 7102cff33a..8cafddf9f6 100644 --- a/scm-ui/ui-webapp/public/locales/en/repos.json +++ b/scm-ui/ui-webapp/public/locales/en/repos.json @@ -110,7 +110,8 @@ "repositoryForm": { "subtitle": "Edit Repository", "submit": "Save", - "initializeRepository": "Initialize repository" + "initializeRepository": "Initialize repository", + "dangerZone": "Danger Zone" }, "sources": { "file-tree": { @@ -193,6 +194,8 @@ }, "deleteRepo": { "button": "Delete Repository", + "subtitle": "Deletes this repository", + "description": "Once a repository was deleted, this cannot be undone. Please be careful with this action.", "confirmAlert": { "title": "Delete repository", "message": "Do you really want to delete the repository?", @@ -200,6 +203,22 @@ "cancel": "No" } }, + "renameRepo": { + "button": "Rename Repository", + "subtitle": "Renames this repository", + "description": "There will be no redirects to the renamed repository.", + "modal": { + "title": "Rename repository", + "label": { + "repoName": "Repository name", + "repoNamespace": "Repository namespace" + }, + "button": { + "rename": "Rename", + "cancel": "Cancel" + } + } + }, "diff": { "changes": { "add": "added", diff --git a/scm-ui/ui-webapp/src/repos/containers/DangerZone.tsx b/scm-ui/ui-webapp/src/repos/containers/DangerZone.tsx new file mode 100644 index 0000000000..97cd60b68d --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/containers/DangerZone.tsx @@ -0,0 +1,74 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React, { FC } from "react"; +import { Repository } from "@scm-manager/ui-types"; +import RenameRepository from "./RenameRepository"; +import DeleteRepo from "./DeleteRepo"; +import styled from "styled-components"; +import { Subtitle } from "@scm-manager/ui-components"; +import { useTranslation } from "react-i18next"; + +type Props = { + repository: Repository; +}; + +const DangerZoneContainer = styled.div` + padding: 1rem; + border: 1px solid #ff6a88; + border-radius: 5px; + > *:not(:last-child) { + padding-bottom: 1.5rem; + border-bottom: solid 2px whitesmoke; + } +`; + +const DangerZone: FC = ({ repository }) => { + const [t] = useTranslation("repos"); + + const dangerZone = []; + if (repository?._links?.rename) { + dangerZone.push(); + } + if (repository?._links?.renameWithNamespace) { + dangerZone.push(); + } + if (repository?._links?.delete) { + dangerZone.push(); + } + + if (dangerZone.length === 0) { + return null; + } + + return ( + <> +
+ + {dangerZone.map(entry => entry)} + + ); +}; + +export default DangerZone; diff --git a/scm-ui/ui-webapp/src/repos/containers/DeleteRepo.tsx b/scm-ui/ui-webapp/src/repos/containers/DeleteRepo.tsx index 4b782fbcaf..8585e98466 100644 --- a/scm-ui/ui-webapp/src/repos/containers/DeleteRepo.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/DeleteRepo.tsx @@ -24,23 +24,21 @@ import React from "react"; import { connect } from "react-redux"; import { compose } from "redux"; -import { withRouter } from "react-router-dom"; +import { RouteComponentProps, withRouter } from "react-router-dom"; import { WithTranslation, withTranslation } from "react-i18next"; import { History } from "history"; import { Repository } from "@scm-manager/ui-types"; -import { confirmAlert, DeleteButton, ErrorNotification, Level } from "@scm-manager/ui-components"; +import { confirmAlert, DeleteButton, ErrorNotification, Level, ButtonGroup } from "@scm-manager/ui-components"; import { deleteRepo, getDeleteRepoFailure, isDeleteRepoPending } from "../modules/repos"; -type Props = WithTranslation & { - loading: boolean; - error: Error; - repository: Repository; - confirmDialog?: boolean; - deleteRepo: (p1: Repository, p2: () => void) => void; - - // context props - history: History; -}; +type Props = RouteComponentProps & + WithTranslation & { + loading: boolean; + error: Error; + repository: Repository; + confirmDialog?: boolean; + deleteRepo: (p1: Repository, p2: () => void) => void; + }; class DeleteRepo extends React.Component { static defaultProps = { @@ -88,9 +86,16 @@ class DeleteRepo extends React.Component { return ( <> -
- } /> + + {t("deleteRepo.subtitle")} +

{t("deleteRepo.description")}

+ + } + right={} + /> ); } diff --git a/scm-ui/ui-webapp/src/repos/containers/EditRepo.tsx b/scm-ui/ui-webapp/src/repos/containers/EditRepo.tsx index a6e9874628..a919423fa4 100644 --- a/scm-ui/ui-webapp/src/repos/containers/EditRepo.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/EditRepo.tsx @@ -25,13 +25,13 @@ import React from "react"; import { connect } from "react-redux"; import { withRouter } from "react-router-dom"; import RepositoryForm from "../components/form"; -import DeleteRepo from "./DeleteRepo"; import { Repository } from "@scm-manager/ui-types"; import { getModifyRepoFailure, isModifyRepoPending, modifyRepo, modifyRepoReset } from "../modules/repos"; import { History } from "history"; import { ErrorNotification } from "@scm-manager/ui-components"; import { ExtensionPoint } from "@scm-manager/ui-extensions"; import { compose } from "redux"; +import DangerZone from "./DangerZone"; type Props = { loading: boolean; @@ -79,7 +79,7 @@ class EditRepo extends React.Component { }; return ( -
+ <> { }} /> - -
+ + ); } } @@ -116,4 +116,4 @@ const mapDispatchToProps = (dispatch: any) => { }; }; -export default compose(connect(mapStateToProps, mapDispatchToProps), withRouter)(EditRepo); +export default compose(connect(mapStateToProps, mapDispatchToProps))(withRouter(EditRepo)); diff --git a/scm-ui/ui-webapp/src/repos/containers/RenameRepository.tsx b/scm-ui/ui-webapp/src/repos/containers/RenameRepository.tsx new file mode 100644 index 0000000000..16ff616798 --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/containers/RenameRepository.tsx @@ -0,0 +1,129 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React, { FC, useState } from "react"; +import { Repository } from "@scm-manager/ui-types"; +import { + ErrorNotification, + Level, + Button, + Loading, + Modal, + InputField, + validation, + ButtonGroup +} from "@scm-manager/ui-components"; +import { useTranslation } from "react-i18next"; + +type Props = { + repository: Repository; + renameNamespace: boolean; +}; + +const RenameRepository: FC = ({ repository, renameNamespace }) => { + const [t] = useTranslation("repos"); + const [error, setError] = useState(undefined); + const [loading, setLoading] = useState(false); + const [showModal, setShowModal] = useState(false); + const [repositoryName, setRepositoryName] = useState(repository.name); + const [repositoryNamespace, setRepositoryNamespace] = useState(repository.namespace); + + if (error) { + return ; + } + + if (loading) { + return ; + } + + const isValid = + validation.isNameValid(repositoryName) && + validation.isNameValid(repositoryNamespace) && + (repository.name !== repositoryName || repository.namespace !== repositoryNamespace); + + const modalBody = ( +
+ + {renameNamespace && ( + + )} +
+ ); + + const footer = ( + <> + +