sdess09 2 лет назад
Родитель
Сommit
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.GetResultWriter(),
 	)
-
 	routes = append(routes, &router.Route{
 		Endpoint: deleteAPIContractRevisionsEndpoint,
 		Handler:  deleteAPIContractRevisionHandler,
 		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
 }

+ 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 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;
 };
 
-const InvitePage: React.FunctionComponent<Props> = ({}) => {
+const InvitePage: React.FunctionComponent<Props> = ({ }) => {
   const {
     currentProject,
     setCurrentModal,
@@ -185,8 +185,11 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
   };
 
   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,}))$/;
-    if (!regex.test(email.toLowerCase())) {
+    if (!regex.test(trimmedEmail.toLowerCase())) {
       setIsInvalidEmail(true);
       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.accepted > b.accepted ? 1 : -1));
     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) {

+ 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 { Context } from "shared/Context";
@@ -18,9 +18,17 @@ import Link from "components/porter/Link";
 import Spacer from "components/porter/Spacer";
 import ProjectDeleteConsent from "./ProjectDeleteConsent";
 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 ValidationError = {
+  hasError: boolean;
+  description?: string;
+};
 type StateType = {
   projectName: string;
   currentTab: string;
@@ -28,62 +36,49 @@ type StateType = {
   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 =
-      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
     // tabOptions.push({
     //   value: "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) {
       //   tabOptions.push({
       //     value: "billing",
@@ -92,54 +87,97 @@ class ProjectSettings extends Component<PropsType, StateType> {
       // }
 
       if (currentProject?.api_tokens_enabled) {
-        tabOptions.push({
+        tabOpts.push({
           value: "api-tokens",
           label: "API Tokens",
         });
       }
 
-      tabOptions.push({
+      tabOpts.push({
         value: "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 />;
     }
 
-    // if (
-    //   this.state.currentTab === "billing" &&
-    //   this.context?.hasBillingEnabled
-    // ) {
-    //   return <BillingPage />;
-    // }
-
-    if (this.state.currentTab === "manage-access") {
+    if (currentTab === "manage-access") {
       return <InvitePage />;
     }
-    else if (this.state.currentTab == "metadata") {
+    else if (currentTab == "metadata") {
       return <Metadata />
-    } else if (this.state.currentTab === "api-tokens") {
+    } else if (currentTab === "api-tokens") {
       return <APITokensSection />;
-    } else if (this.state.currentTab === "billing") {
+    } else if (currentTab === "billing") {
       return (
         <Placeholder>
           <Helper>
             Visit the{" "}
             <a
-              href={`/api/projects/${this.context.currentProject?.id}/billing/redirect`}
+              href={`/api/projects/${context.currentProject?.id}/billing/redirect`}
             >
               billing portal
             </a>{" "}
@@ -150,9 +188,30 @@ class ProjectSettings extends Component<PropsType, StateType> {
     } else {
       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>
+          <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>
             Permanently delete this project. This will destroy all clusters tied
             to this project that have been provisioned by Porter. Note that this
@@ -162,41 +221,40 @@ class ProjectSettings extends Component<PropsType, StateType> {
 
           <DeleteButton
             onClick={() => {
-              this.setState({ showCostConfirmModal: true });
+              setShowCostConfirmModal(true);
             }}
           >
             Delete project
           </DeleteButton>
           <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;
 
 export default withRouter(withAuth(ProjectSettings));
@@ -217,7 +275,7 @@ const Placeholder = styled.div`
 const Warning = styled.div`
   font-size: 13px;
   color: ${(props: { highlight: boolean; makeFlush?: boolean }) =>
-    props.highlight ? "#f5cb42" : ""};
+    props.highlight ? "#f5cb42" : "#aaaabb"}
   margin-bottom: 20px;
 `;
 

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

@@ -135,7 +135,16 @@ const updateCluster = baseApi<
 >("POST", (pathParams) => {
   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<
   {
     name: string;
@@ -812,11 +821,9 @@ const getBranchHead = baseApi<
     branch: string;
   }
 >("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<
@@ -2856,6 +2863,7 @@ export default {
   overwriteAWSIntegration,
   updateCluster,
   renameCluster,
+  renameProject,
   createAzureIntegration,
   createGitlabIntegration,
   createEmailVerification,