Browse Source

Project Rename (#3452)

sdess09 2 năm trước cách đây
mục cha
commit
7c2efbac46

+ 51 - 0
api/server/handlers/project/rename.go

@@ -0,0 +1,51 @@
+package project
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// RenameProjectHandler Renames a project
+type RenameProjectHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewRenameProjectHandler renames the project with the given name
+func NewRenameProjectHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *RenameProjectHandler {
+	return &RenameProjectHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *RenameProjectHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	request := &types.UpdateProjectNameRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	if request.Name != "" && proj.Name != request.Name {
+		proj.Name = request.Name
+	}
+
+	project, err := c.Repo().Project().UpdateProject(proj)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, project.ToProjectType())
+}

+ 28 - 1
api/server/router/project.go

@@ -1395,12 +1395,39 @@ func getProjectRoutes(
 		factory.GetDecoderValidator(),
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 		factory.GetResultWriter(),
 	)
 	)
-
 	routes = append(routes, &router.Route{
 	routes = append(routes, &router.Route{
 		Endpoint: deleteAPIContractRevisionsEndpoint,
 		Endpoint: deleteAPIContractRevisionsEndpoint,
 		Handler:  deleteAPIContractRevisionHandler,
 		Handler:  deleteAPIContractRevisionHandler,
 		Router:   r,
 		Router:   r,
 	})
 	})
 
 
+	// POST /api/projects/{project_id}/rename -> cluster.newRenamProject
+	renameProjectEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/rename",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	renameProjectHandler := project.NewRenameProjectHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: renameProjectEndpoint,
+		Handler:  renameProjectHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 	return routes, newPath
 }
 }

+ 5 - 0
api/types/project.go

@@ -144,3 +144,8 @@ type UpdateOnboardingStepRequest struct {
 	// ExternalId used as a 'password' for the aws assume role chain to porter-manager role
 	// ExternalId used as a 'password' for the aws assume role chain to porter-manager role
 	ExternalId string `json:"external_id"`
 	ExternalId string `json:"external_id"`
 }
 }
+
+// UpdateProjectNameRequest takes in a name to rename projects
+type UpdateProjectNameRequest struct {
+	Name string `json:"name" form:"required"`
+}

+ 7 - 5
dashboard/src/main/home/project-settings/InviteList.tsx

@@ -24,7 +24,7 @@ export type Collaborator = {
   kind: string;
   kind: string;
 };
 };
 
 
-const InvitePage: React.FunctionComponent<Props> = ({}) => {
+const InvitePage: React.FunctionComponent<Props> = ({ }) => {
   const {
   const {
     currentProject,
     currentProject,
     setCurrentModal,
     setCurrentModal,
@@ -185,8 +185,11 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
   };
   };
 
 
   const validateEmail = () => {
   const validateEmail = () => {
+    const trimmedEmail = email.trim();
+    setEmail(trimmedEmail);
+
     const regex = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
     const regex = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
-    if (!regex.test(email.toLowerCase())) {
+    if (!regex.test(trimmedEmail.toLowerCase())) {
       setIsInvalidEmail(true);
       setIsInvalidEmail(true);
       return;
       return;
     }
     }
@@ -335,9 +338,8 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
     inviteList.sort((a: any, b: any) => (a.email > b.email ? 1 : -1));
     inviteList.sort((a: any, b: any) => (a.email > b.email ? 1 : -1));
     inviteList.sort((a: any, b: any) => (a.accepted > b.accepted ? 1 : -1));
     inviteList.sort((a: any, b: any) => (a.accepted > b.accepted ? 1 : -1));
     const buildInviteLink = (token: string) => `
     const buildInviteLink = (token: string) => `
-      ${isHTTPS ? "https://" : ""}${window.location.host}/api/projects/${
-      currentProject.id
-    }/invites/${token}
+      ${isHTTPS ? "https://" : ""}${window.location.host}/api/projects/${currentProject.id
+      }/invites/${token}
     `;
     `;
 
 
     if (!user) {
     if (!user) {

+ 149 - 91
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -1,4 +1,4 @@
-import React, { Component } from "react";
+import React, { Component, useContext, useEffect, useState } from "react";
 import styled from "styled-components";
 import styled from "styled-components";
 
 
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
@@ -18,9 +18,17 @@ import Link from "components/porter/Link";
 import Spacer from "components/porter/Spacer";
 import Spacer from "components/porter/Spacer";
 import ProjectDeleteConsent from "./ProjectDeleteConsent";
 import ProjectDeleteConsent from "./ProjectDeleteConsent";
 import Metadata from "./Metadata";
 import Metadata from "./Metadata";
+import Button from "components/porter/Button";
+import Input from "components/porter/Input";
+import { isAlphanumeric } from "shared/common";
+import api from "shared/api";
+import Error from "components/porter/Error";
 
 
 type PropsType = RouteComponentProps & WithAuthProps & {};
 type PropsType = RouteComponentProps & WithAuthProps & {};
-
+type ValidationError = {
+  hasError: boolean;
+  description?: string;
+};
 type StateType = {
 type StateType = {
   projectName: string;
   projectName: string;
   currentTab: string;
   currentTab: string;
@@ -28,62 +36,49 @@ type StateType = {
   showCostConfirmModal: boolean;
   showCostConfirmModal: boolean;
 };
 };
 
 
-class ProjectSettings extends Component<PropsType, StateType> {
-  state = {
-    projectName: "",
-    currentTab: "manage-access",
-    tabOptions: [] as { value: string; label: string }[],
-    showCostConfirmModal: false,
-  };
+function ProjectSettings(props: any) {
+  const context = useContext(Context);
+
+  const [projectName, setProjectName] = useState("");
+  const [currentTab, setCurrentTab] = useState("manage-access");
+  const [tabOptions, setTabOptions] = useState([]);
+  const [showCostConfirmModal, setShowCostConfirmModal] = useState(false);
+  const [name, setName] = useState(context?.currentProject?.name);
+  const [disabled, setDisabled] = useState<boolean>(false);
+  const [buttonStatus, setButtonStatus] = useState<React.ReactNode>("");
 
 
-  componentDidUpdate(prevProps: PropsType) {
+  useEffect(() => {
     const selectedTab =
     const selectedTab =
-      getQueryParam(this.props, "selected_tab") || "manage-access";
+      getQueryParam(props, "selected_tab") || "manage-access";
 
 
-    if (
-      prevProps.location.search !== this.props.location.search &&
-      this.state.currentTab !== selectedTab
-    ) {
-      this.setState({ currentTab: selectedTab });
+    if (currentTab !== selectedTab) {
+      setCurrentTab(selectedTab);
+    }
+  }, [props.location.search]);
+  useEffect(() => {
+    const currentProject = context.currentProject;
+    if (projectName !== currentProject.name) {
+      setProjectName(currentProject.name);
     }
     }
 
 
-    // if (
-    //   this.context?.hasBillingEnabled &&
-    //   !this.state.tabOptions.find((t) => t.value === "billing")
-    // ) {
-    //   const tabOptions = this.state.tabOptions;
-    //   tabOptions.splice(1, 0, { value: "billing", label: "Billing" });
-    //   this.setState({ tabOptions });
-    //   return;
-    // }
-
-    // if (
-    //   !this.context?.hasBillingEnabled &&
-    //   this.state.tabOptions.find((t) => t.value === "billing")
-    // ) {
-    //   const tabOptions = this.state.tabOptions;
-    //   const billingIndex = this.state.tabOptions.findIndex(
-    //     (t) => t.value === "billing"
-    //   );
-    //   tabOptions.splice(billingIndex, 1);
-    // }
-  }
+  }, []);
 
 
-  componentDidMount() {
-    let { currentProject } = this.context;
 
 
-    if (this.state.projectName !== currentProject.name) {
-      this.setState({ projectName: currentProject.name });
+  useEffect(() => {
+    let { currentProject } = context;
+    if (projectName !== currentProject.name) {
+      setProjectName(currentProject.name);
     }
     }
-    const tabOptions = [];
-    tabOptions.push({ value: "manage-access", label: "Manage access" });
+
+    const tabOpts = [];
+    tabOpts.push({ value: "manage-access", label: "Manage access" });
     // ? Disabled for now https://discord.com/channels/542888846271184896/1059277393031856208/1059277395913351258
     // ? Disabled for now https://discord.com/channels/542888846271184896/1059277393031856208/1059277395913351258
     // tabOptions.push({
     // tabOptions.push({
     //   value: "billing",
     //   value: "billing",
     //   label: "Billing",
     //   label: "Billing",
     // });
     // });
-    tabOptions.push({ value: "metadata", label: "Metadata" });
-    if (this.props.isAuthorized("settings", "", ["get", "delete"])) {
+    tabOpts.push({ value: "metadata", label: "Metadata" });
+    if (props.isAuthorized("settings", "", ["get", "delete"])) {
       // if (this.context?.hasBillingEnabled) {
       // if (this.context?.hasBillingEnabled) {
       //   tabOptions.push({
       //   tabOptions.push({
       //     value: "billing",
       //     value: "billing",
@@ -92,54 +87,97 @@ class ProjectSettings extends Component<PropsType, StateType> {
       // }
       // }
 
 
       if (currentProject?.api_tokens_enabled) {
       if (currentProject?.api_tokens_enabled) {
-        tabOptions.push({
+        tabOpts.push({
           value: "api-tokens",
           value: "api-tokens",
           label: "API Tokens",
           label: "API Tokens",
         });
         });
       }
       }
 
 
-      tabOptions.push({
+      tabOpts.push({
         value: "additional-settings",
         value: "additional-settings",
         label: "Additional settings",
         label: "Additional settings",
       });
       });
     }
     }
 
 
-    if (!_.isEqual(tabOptions, this.state.tabOptions)) {
-      this.setState({ tabOptions });
+
+    if (!_.isEqual(tabOpts, tabOptions)) {
+      setTabOptions(tabOpts);
+    }
+
+    const selectedTab = getQueryParam(props, "selected_tab");
+    if (selectedTab && selectedTab !== currentTab) {
+      setCurrentTab(selectedTab);
+    }
+
+  }, [context, projectName, currentTab, props, tabOptions]);
+
+  const validateProjectName = (): ValidationError => {
+    if (name === "") {
+      return {
+        hasError: true,
+        description: "The name cannot be empty. Please fill the input.",
+      };
+    }
+    if (!isAlphanumeric(name)) {
+      return {
+        hasError: true,
+        description:
+          'Please be sure that the text is alphanumeric. (lowercase letters, numbers, and "-" only)',
+      };
     }
     }
+    if (name.length > 25) {
+      return {
+        hasError: true,
+        description:
+          "The length of the name cannot be more than 25 characters.",
+      };
+    }
+
+    return {
+      hasError: false,
+    };
+  };
+
+  const handleNameChange = async () => {
+    try {
+      setButtonStatus("loading");
 
 
-    const selectedTab = getQueryParam(this.props, "selected_tab");
-    if (selectedTab && selectedTab !== this.state.currentTab) {
-      this.setState({ currentTab: selectedTab });
+      await api.renameProject(
+        "<token>",
+        {
+          name: name,
+        },
+        {
+          project_id: context.currentProject.id,
+        })
+      setButtonStatus("success");
+      window.location.reload();
+
+    } catch (err) {
+      console.log(err)
+      setButtonStatus(<Error message="Unable to rename project" />);
     }
     }
   }
   }
 
 
-  renderTabContents = () => {
-    if (!this.props.isAuthorized("settings", "", ["get", "delete"])) {
+  const renderTabContents = () => {
+    if (!props.isAuthorized("settings", "", ["get", "delete"])) {
       return <InvitePage />;
       return <InvitePage />;
     }
     }
 
 
-    // if (
-    //   this.state.currentTab === "billing" &&
-    //   this.context?.hasBillingEnabled
-    // ) {
-    //   return <BillingPage />;
-    // }
-
-    if (this.state.currentTab === "manage-access") {
+    if (currentTab === "manage-access") {
       return <InvitePage />;
       return <InvitePage />;
     }
     }
-    else if (this.state.currentTab == "metadata") {
+    else if (currentTab == "metadata") {
       return <Metadata />
       return <Metadata />
-    } else if (this.state.currentTab === "api-tokens") {
+    } else if (currentTab === "api-tokens") {
       return <APITokensSection />;
       return <APITokensSection />;
-    } else if (this.state.currentTab === "billing") {
+    } else if (currentTab === "billing") {
       return (
       return (
         <Placeholder>
         <Placeholder>
           <Helper>
           <Helper>
             Visit the{" "}
             Visit the{" "}
             <a
             <a
-              href={`/api/projects/${this.context.currentProject?.id}/billing/redirect`}
+              href={`/api/projects/${context.currentProject?.id}/billing/redirect`}
             >
             >
               billing portal
               billing portal
             </a>{" "}
             </a>{" "}
@@ -150,9 +188,30 @@ class ProjectSettings extends Component<PropsType, StateType> {
     } else {
     } else {
       return (
       return (
         <>
         <>
-          <Heading isAtTop={true}>Delete project</Heading>
-          <Helper>
+
+          <Heading isAtTop={true}>Rename Project</Heading>
+
+          <Helper color={validateProjectName().hasError ? "#f5cb42" : "#aaaabb"}>
+            (lowercase letters, numbers, and "-" only)
           </Helper>
           </Helper>
+          <Input placeholder={"ex: perspective-vortex"} value={name} setValue={setName} width={"500px"}>
+          </Input>
+          <Spacer y={1} />
+          <Button
+            onClick={() => {
+              handleNameChange()
+            }}
+            status={buttonStatus}
+            loadingText={"Updating..."}
+            disabled={validateProjectName().hasError}
+          >
+            Change name
+          </Button>
+
+          <Spacer y={1} />
+          <Spacer y={1} />
+          <Heading isAtTop={true}>Delete project</Heading>
+
           <Helper>
           <Helper>
             Permanently delete this project. This will destroy all clusters tied
             Permanently delete this project. This will destroy all clusters tied
             to this project that have been provisioned by Porter. Note that this
             to this project that have been provisioned by Porter. Note that this
@@ -162,41 +221,40 @@ class ProjectSettings extends Component<PropsType, StateType> {
 
 
           <DeleteButton
           <DeleteButton
             onClick={() => {
             onClick={() => {
-              this.setState({ showCostConfirmModal: true });
+              setShowCostConfirmModal(true);
             }}
             }}
           >
           >
             Delete project
             Delete project
           </DeleteButton>
           </DeleteButton>
           <ProjectDeleteConsent
           <ProjectDeleteConsent
-            setShowCostConfirmModal={(show: boolean) => this.setState({ showCostConfirmModal: show })}
-            show={this.state.showCostConfirmModal}  // <-- Pass these props
+            setShowCostConfirmModal={setShowCostConfirmModal}
+            show={showCostConfirmModal}  // <-- Pass these props
           />
           />
         </>
         </>
       );
       );
     }
     }
   };
   };
 
 
-  render() {
-    return (
-      <StyledProjectSettings>
-        <DashboardHeader
-          image={settings}
-          title="Project settings"
-          description="Configure access permissions and additional project settings."
-          disableLineBreak
-        />
-        <TabRegion
-          currentTab={this.state.currentTab}
-          setCurrentTab={(x: string) => this.setState({ currentTab: x })}
-          options={this.state.tabOptions}
-        >
-          {this.renderTabContents()}
-        </TabRegion>
-      </StyledProjectSettings>
-    );
-  }
+  return (
+    <StyledProjectSettings>
+      <DashboardHeader
+        image={settings}
+        title="Project settings"
+        description="Configure access permissions and additional project settings."
+        disableLineBreak
+      />
+      <TabRegion
+        currentTab={currentTab}
+        setCurrentTab={setCurrentTab}
+        options={tabOptions}
+      >
+        {renderTabContents()}
+      </TabRegion>
+    </StyledProjectSettings>
+  );
 }
 }
 
 
+
 ProjectSettings.contextType = Context;
 ProjectSettings.contextType = Context;
 
 
 export default withRouter(withAuth(ProjectSettings));
 export default withRouter(withAuth(ProjectSettings));
@@ -217,7 +275,7 @@ const Placeholder = styled.div`
 const Warning = styled.div`
 const Warning = styled.div`
   font-size: 13px;
   font-size: 13px;
   color: ${(props: { highlight: boolean; makeFlush?: boolean }) =>
   color: ${(props: { highlight: boolean; makeFlush?: boolean }) =>
-    props.highlight ? "#f5cb42" : ""};
+    props.highlight ? "#f5cb42" : "#aaaabb"}
   margin-bottom: 20px;
   margin-bottom: 20px;
 `;
 `;
 
 

+ 14 - 6
dashboard/src/shared/api.tsx

@@ -135,7 +135,16 @@ const updateCluster = baseApi<
 >("POST", (pathParams) => {
 >("POST", (pathParams) => {
   return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}`;
   return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}`;
 });
 });
-
+const renameProject = baseApi<
+  {
+    name: string | undefined;
+  },
+  {
+    project_id: number;
+  }
+>("POST", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/rename`;
+});
 const renameCluster = baseApi<
 const renameCluster = baseApi<
   {
   {
     name: string;
     name: string;
@@ -812,11 +821,9 @@ const getBranchHead = baseApi<
     branch: string;
     branch: string;
   }
   }
 >("GET", (pathParams) => {
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${
-    pathParams.git_repo_id
-  }/repos/${pathParams.kind}/${pathParams.owner}/${
-    pathParams.name
-  }/${encodeURIComponent(pathParams.branch)}/head`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
+    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
+    }/${encodeURIComponent(pathParams.branch)}/head`;
 });
 });
 
 
 const validatePorterApp = baseApi<
 const validatePorterApp = baseApi<
@@ -2856,6 +2863,7 @@ export default {
   overwriteAWSIntegration,
   overwriteAWSIntegration,
   updateCluster,
   updateCluster,
   renameCluster,
   renameCluster,
+  renameProject,
   createAzureIntegration,
   createAzureIntegration,
   createGitlabIntegration,
   createGitlabIntegration,
   createEmailVerification,
   createEmailVerification,