dgtown 2 éve
szülő
commit
4d86536267

+ 37 - 0
api/server/handlers/porter_app/create_and_update_events.go

@@ -73,6 +73,43 @@ func (p *CreateUpdatePorterAppEventHandler) ServeHTTP(w http.ResponseWriter, r *
 		telemetry.AttributeKV{Key: "deployment-target-id", Value: request.DeploymentTargetID},
 	)
 
+	if request.DeploymentTargetID != "" {
+		deploymentTargetUUID, err := uuid.Parse(request.DeploymentTargetID)
+		if err != nil {
+			e := telemetry.Error(ctx, span, err, "error parsing deployment target id")
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
+			return
+		}
+		if deploymentTargetUUID == uuid.Nil {
+			e := telemetry.Error(ctx, span, err, "deployment target id cannot be nil")
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
+			return
+		}
+
+		deploymentTarget, err := p.Repo().DeploymentTarget().DeploymentTargetByID(deploymentTargetUUID)
+		if err != nil {
+			e := telemetry.Error(ctx, span, err, "error getting deployment target")
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusInternalServerError))
+			return
+		}
+		telemetry.WithAttributes(span,
+			telemetry.AttributeKV{Key: "deployment-target-project-id", Value: deploymentTarget.ProjectID},
+			telemetry.AttributeKV{Key: "deployment-target-cluster-id", Value: deploymentTarget.ClusterID},
+		)
+		project, err = p.Repo().Project().ReadProject(uint(deploymentTarget.ProjectID))
+		if err != nil {
+			e := telemetry.Error(ctx, span, err, "error getting tenant project")
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusInternalServerError))
+			return
+		}
+		cluster, err = p.Repo().Cluster().ReadCluster(project.ID, uint(deploymentTarget.ClusterID))
+		if err != nil {
+			e := telemetry.Error(ctx, span, err, "error getting tenant cluster")
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusInternalServerError))
+			return
+		}
+	}
+
 	if request.Type == types.PorterAppEventType_Build {
 		validateApplyV2 := project.GetFeatureFlag(models.ValidateApplyV2, p.Config().LaunchDarklyClient)
 		reportBuildStatus(ctx, request, p.Config(), user, project, appName, validateApplyV2)

+ 167 - 0
api/server/handlers/project/create_hosted_project.go

@@ -0,0 +1,167 @@
+package project
+
+import (
+	"net/http"
+
+	"connectrpc.com/connect"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+
+	"github.com/porter-dev/porter/internal/telemetry"
+
+	"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/analytics"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+type HostedProjectCreateHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewHostedProjectCreateHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *HostedProjectCreateHandler {
+	return &HostedProjectCreateHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+type CreateHostedProjectRequest struct {
+	Name string `json:"name" form:"required"`
+	Code string `json:"code" form:"required"`
+}
+
+type CreateHostedProjectResponse types.Project
+
+func (p *HostedProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-hosted-project-create")
+	defer span.End()
+
+	request := &CreateHostedProjectRequest{}
+
+	ok := p.DecodeAndValidate(w, r, request)
+	if !ok {
+		return
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "project-name", Value: request.Name},
+		telemetry.AttributeKV{Key: "code", Value: request.Code},
+	)
+
+	if request.Name == "" {
+		err := telemetry.Error(ctx, span, nil, "project name cannot be empty")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	if request.Code == "" {
+		err := telemetry.Error(ctx, span, nil, "code cannot be empty")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	// read the user from context
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+
+	proj := &models.Project{
+		Name: request.Name,
+	}
+
+	var err error
+	proj, _, err = CreateHostedProjectWithUser(p.Repo().Project(), proj, user)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error creating project with user")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	req := connect.NewRequest(&porterv1.ConnectHostedProjectRequest{
+		ProjectId: int64(proj.ID),
+		Code:      request.Code,
+	})
+	_, err = p.Config().ClusterControlPlaneClient.ConnectHostedProject(ctx, req)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error connecting hosted project")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	// I don't think I need this?
+
+	//// create onboarding flow set to the first step
+	//_, err = p.Repo().Onboarding().CreateProjectOnboarding(&models.Onboarding{
+	//	ProjectID:   proj.ID,
+	//	CurrentStep: types.StepConnectSource,
+	//})
+	//if err != nil {
+	//	p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	//	return
+	//}
+
+	// create default project usage restriction
+	_, err = p.Repo().ProjectUsage().CreateProjectUsage(&models.ProjectUsage{
+		ProjectID:      proj.ID,
+		ResourceCPU:    types.BasicPlan.ResourceCPU,
+		ResourceMemory: types.BasicPlan.ResourceMemory,
+		Clusters:       types.BasicPlan.Clusters,
+		Users:          types.BasicPlan.Users,
+	})
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	p.WriteResult(w, r, proj.ToProjectType(p.Config().LaunchDarklyClient))
+
+	// add project to billing team
+	_, err = p.Config().BillingManager.CreateTeam(user, proj)
+
+	if err != nil {
+		// we do not write error response, since setting up billing error can be
+		// resolved later and may not be fatal
+		p.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
+	}
+
+	p.Config().AnalyticsClient.Track(analytics.ProjectCreateTrack(&analytics.ProjectCreateDeleteTrackOpts{
+		ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, proj.ID),
+	}))
+}
+
+func CreateHostedProjectWithUser(
+	projectRepo repository.ProjectRepository,
+	proj *models.Project,
+	user *models.User,
+) (*models.Project, *models.Role, error) {
+	proj, err := projectRepo.CreateProject(proj)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	// create a new Role with the user as the admin
+	role, err := projectRepo.CreateProjectRole(proj, &models.Role{
+		Role: types.Role{
+			UserID:    user.ID,
+			ProjectID: proj.ID,
+			Kind:      types.RoleAdmin,
+		},
+	})
+	if err != nil {
+		return nil, nil, err
+	}
+
+	// read the project again to get the model with the role attached
+	proj, err = projectRepo.ReadProject(proj.ID)
+
+	if err != nil {
+		return nil, nil, err
+	}
+
+	return proj, role, nil
+}

+ 25 - 0
api/server/router/user.go

@@ -219,6 +219,31 @@ func getUserRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/hosted -> project.NewHostedProjectCreateHandler
+	createHostedEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/projects/hosted",
+			},
+			Scopes: []types.PermissionScope{types.UserScope},
+		},
+	)
+
+	createHostedHandler := project.NewHostedProjectCreateHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: createHostedEndpoint,
+		Handler:  createHostedHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects -> project.NewProjectListHandler
 	listEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 28 - 25
dashboard/src/main/home/Home.tsx

@@ -1,5 +1,5 @@
 import React, { useEffect, useState, useContext, useRef } from "react";
-import { Route, RouteComponentProps, Switch, withRouter } from "react-router";
+import { Route, type RouteComponentProps, Switch, withRouter } from "react-router";
 import styled, { ThemeProvider } from "styled-components";
 import { createPortal } from "react-dom";
 
@@ -7,8 +7,8 @@ import api from "shared/api";
 import midnight from "shared/themes/midnight";
 import standard from "shared/themes/standard";
 import { Context } from "shared/Context";
-import { PorterUrl, pushFiltered, pushQueryParams } from "shared/routing";
-import { ClusterType, ProjectType, ProjectListType } from "shared/types";
+import { type PorterUrl, pushFiltered, pushQueryParams } from "shared/routing";
+import { type ClusterType, type ProjectType, type ProjectListType } from "shared/types";
 
 import ConfirmOverlay from "components/ConfirmOverlay";
 import Loading from "components/Loading";
@@ -26,7 +26,7 @@ import DatabaseDashboard from "./database-dashboard/DatabaseDashboard";
 import CreateDatabase from "./database-dashboard/CreateDatabase";
 
 import { fakeGuardedRoute } from "shared/auth/RouteGuard";
-import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+import { withAuth, type WithAuthProps } from "shared/auth/AuthorizationHoc";
 import discordLogo from "../../assets/discord.svg";
 import Onboarding from "./onboarding/Onboarding";
 import ModalHandler from "./ModalHandler";
@@ -48,6 +48,7 @@ import DeploymentTargetProvider from "shared/DeploymentTargetContext";
 import PreviewEnvs from "./cluster-dashboard/preview-environments/v2/PreviewEnvs";
 import SetupApp from "./cluster-dashboard/preview-environments/v2/setup-app/SetupApp";
 import ClusterResourcesProvider from "shared/ClusterResourcesContext";
+import NewHostedProject from "./new-project/NewHostedProject";
 
 // Guarded components
 const GuardedProjectSettings = fakeGuardedRoute("settings", "", [
@@ -123,10 +124,10 @@ const Home: React.FC<Props> = (props) => {
   };
 
   const getProjects = async (id?: number) => {
-    let { currentProject } = props;
-    let queryString = window.location.search;
-    let urlParams = new URLSearchParams(queryString);
-    let projectId = urlParams.get("project_id");
+    const { currentProject } = props;
+    const queryString = window.location.search;
+    const urlParams = new URLSearchParams(queryString);
+    const projectId = urlParams.get("project_id");
     if (!projectId && currentProject?.id) {
       pushQueryParams(props, { project_id: currentProject.id.toString() });
     }
@@ -154,7 +155,7 @@ const Home: React.FC<Props> = (props) => {
         }
 
         const project = await api
-          .getProject("<token>", {}, { id: id })
+          .getProject("<token>", {}, { id })
           .then((res) => res.data as ProjectType);
 
         setCurrentProject(project);
@@ -204,18 +205,18 @@ const Home: React.FC<Props> = (props) => {
   useEffect(() => {
     checkOnboarding();
     checkIfCanCreateProject();
-    let { match } = props;
+    const { match } = props;
 
     // Handle redirect from DO
-    let queryString = window.location.search;
-    let urlParams = new URLSearchParams(queryString);
+    const queryString = window.location.search;
+    const urlParams = new URLSearchParams(queryString);
 
-    let err = urlParams.get("error");
+    const err = urlParams.get("error");
     if (err) {
       setCurrentError(err);
     }
 
-    let defaultProjectId = parseInt(urlParams.get("project_id"));
+    const defaultProjectId = parseInt(urlParams.get("project_id"));
 
     setGhRedirect(urlParams.get("gh_oauth") !== null);
     urlParams.delete("gh_oauth");
@@ -288,9 +289,9 @@ const Home: React.FC<Props> = (props) => {
   }, [props.currentProject?.id]);
 
   useEffect(() => {
-    let queryString = window.location.search;
-    let urlParams = new URLSearchParams(queryString);
-    let err = urlParams.get("error");
+    const queryString = window.location.search;
+    const urlParams = new URLSearchParams(queryString);
+    const err = urlParams.get("error");
     if (
       !hasFinishedOnboarding &&
       props.history.location.pathname &&
@@ -326,7 +327,7 @@ const Home: React.FC<Props> = (props) => {
 
       setProjects(projectList);
       if (!projectList.length) {
-        setCurrentProject(null, () => redirectToNewProject());
+        setCurrentProject(null, () => { redirectToNewProject(); });
       } else {
         const project = await api
           .getProject("<token>", {}, { id: projectList[0].id })
@@ -360,18 +361,18 @@ const Home: React.FC<Props> = (props) => {
 
     try {
       const res = await api.getClusters<
-        {
+        Array<{
           infra_id?: number;
           name: string;
-        }[]
+        }>
       >("<token>", {}, { id: currentProject?.id });
 
-      const destroyInfraPromises = res.data.map((cluster) => {
+      const destroyInfraPromises = res.data.map(async (cluster) => {
         if (!cluster.infra_id) {
           return undefined;
         }
 
-        return api.destroyInfra(
+        return await api.destroyInfra(
           "<token>",
           {},
           { project_id: currentProject.id, infra_id: cluster.infra_id }
@@ -463,13 +464,15 @@ const Home: React.FC<Props> = (props) => {
                 <Route path="/databases">
                   <DatabaseDashboard />
                 </Route>
-
                 <Route path="/addons/new">
                   <NewAddOnFlow />
                 </Route>
                 <Route path="/addons">
                   <AddOnDashboard />
                 </Route>
+                <Route path="/new-project/hosted">
+                  <NewHostedProject />
+                </Route>
                 <Route
                   path="/new-project"
                   render={() => {
@@ -525,7 +528,7 @@ const Home: React.FC<Props> = (props) => {
                   render={() => {
                     if (currentCluster?.id === -1) {
                       return <Loading />;
-                    } else if (!currentCluster || !currentCluster.name) {
+                    } else if (!currentCluster?.name) {
                       return (
                         <DashboardWrapper>
                           <NoClusterPlaceHolder></NoClusterPlaceHolder>
@@ -587,7 +590,7 @@ const Home: React.FC<Props> = (props) => {
                     : ""
                 }
                 onYes={handleDelete}
-                onNo={() => setCurrentModal(null, null)}
+                onNo={() => { setCurrentModal(null, null); }}
               />,
               document.body
             )}

+ 322 - 0
dashboard/src/main/home/new-project/NewHostedProject.tsx

@@ -0,0 +1,322 @@
+import React, {useContext, useEffect, useMemo, useState} from "react";
+import {Context} from "../../../shared/Context";
+import {useRouting} from "../../../shared/routing";
+import {isAlphanumeric, isTrueAlphanumeric} from "../../../shared/common";
+import api from "../../../shared/api";
+import {type ProjectListType} from "../../../shared/types";
+import {trackCreateNewProject} from "../../../shared/anayltics";
+import backArrow from "../../../assets/back_arrow.png";
+import TitleSection from "../../../components/TitleSection";
+import Helper from "../../../components/form-components/Helper";
+import gradient from "../../../assets/gradient.png";
+import InputRow from "../../../components/form-components/InputRow";
+import PageIllustration from "../../../components/PageIllustration";
+import styled from "styled-components";
+import SaveButton from "../../../components/SaveButton";
+
+type Props = {};
+
+type ValidationError = {
+    hasError: boolean;
+    description?: string;
+};
+
+const NewHostedProject: React.FC<Props> = ({}) => {
+    const {
+        user,
+        setProjects,
+        setCurrentProject,
+        projects,
+    } = useContext(Context);
+    const { pushFiltered } = useRouting();
+    const [buttonStatus, setButtonStatus] = useState("");
+    const [name, setName] = useState("");
+    const [code, setCode] = useState("");
+
+    const isFirstProject = useMemo(() => {
+        return !(projects?.length >= 1);
+    }, [projects]);
+
+    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 createHostedProject = async () => {
+        const projectName = name;
+        const projectCode = code
+        setButtonStatus("loading");
+        const validation = validateProjectName();
+
+        if (validation.hasError) {
+            setButtonStatus(validation.description);
+            return;
+        }
+
+        try {
+            const project = await api
+                .createHostedProject("<token>", { name: projectName, code: projectCode }, {})
+                .then((res) => res.data);
+
+            const projectList = await api
+                .getProjects(
+                    "<token>",
+                    {},
+                    {
+                        id: user.userId,
+                    }
+                )
+                .then((res) => res.data as ProjectListType[]);
+            setProjects(projectList);
+            setCurrentProject(project);
+            setButtonStatus("successful");
+            trackCreateNewProject();
+            pushFiltered("/dashboard", []);
+        } catch (error) {
+            setButtonStatus("Couldn't create project, try again.");
+            console.log(error);
+        }
+    };
+
+    const renderContents = () => {
+        return (
+            <>
+                <FadeWrapper>
+                    {!isFirstProject && (
+                        <BackButton
+                            onClick={() => {
+                                pushFiltered("/dashboard", []);
+                            }}
+                        >
+                            <BackButtonImg src={backArrow} />
+                        </BackButton>
+                    )}
+                    <TitleSection>New hosted project</TitleSection>
+                </FadeWrapper>
+                <FadeWrapper delay="0.7s">
+                    <Helper>
+                        Project name
+                        <Warning highlight={validateProjectName().hasError}>
+                            (letters and numbers only)
+                        </Warning>
+                        <Required>*</Required>
+                    </Helper>
+                </FadeWrapper>
+                <SlideWrapper delay="1.2s">
+                    <InputWrapper>
+                        <ProjectIcon>
+                            <ProjectImage src={gradient} />
+                            <Letter>
+                                {name ? name.toUpperCase().substring(0, 1) : "-"}
+                            </Letter>
+                        </ProjectIcon>
+                        <InputRow
+                            type="string"
+                            value={name}
+                            setValue={(x: string) => {
+                                setButtonStatus("");
+                                setName(x);
+                            }}
+                            placeholder="ex: perspective-vortex"
+                            width="470px"
+                            disabled={buttonStatus === "loading"}
+                        />
+                    </InputWrapper>
+                </SlideWrapper>
+                <SlideWrapper delay="2.2s">
+                    <InputWrapper>
+                        <Helper>
+                            Code:
+                        </Helper>
+                        <InputRow
+                            type="string"
+                            value={code}
+                            setValue={(x: string) => {
+                                setButtonStatus("");
+                                setCode(x);
+                            }}
+                            placeholder="ex: ad0Fq4"
+                            width="420px"
+                            disabled={buttonStatus === "loading"}
+                        />
+                    </InputWrapper>
+                    <NewProjectSaveButton
+                        text="Create project"
+                        disabled={false}
+                        onClick={createHostedProject}
+                        status={buttonStatus}
+                        makeFlush={true}
+                        clearPosition={true}
+                        statusPosition="right"
+                        saveText="Creating project..."
+                        successText="Project created successfully!"
+                    />
+
+                </SlideWrapper>
+            </>
+        );
+    };
+
+    return (
+        <Wrapper>
+            <StyledNewProject>
+                <PageIllustration />
+                {renderContents()}
+            </StyledNewProject>
+        </Wrapper>
+    );
+};
+
+export default NewHostedProject;
+
+
+const Wrapper = styled.div`
+  max-width: 700px;
+  width: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-top: -6%;
+  padding-bottom: 5%;
+  min-width: 300px;
+  position: relative;
+`;
+
+const FadeWrapper = styled.div<{ delay?: string }>`
+  opacity: 0;
+  animation: fadeIn 0.5s ${(props) => props.delay || "0.2s"};
+  animation-fill-mode: forwards;
+
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const SlideWrapper = styled.div<{ delay?: string }>`
+  opacity: 0;
+  animation: slideIn 0.7s 1.3s;
+  animation-fill-mode: forwards;
+
+  @keyframes slideIn {
+    from {
+      opacity: 0;
+      transform: translateX(30px);
+    }
+    to {
+      opacity: 1;
+      transform: translateX(0);
+    }
+  }
+`;
+
+const Required = styled.div`
+  margin-left: 8px;
+  color: #fc4976;
+  display: inline-block;
+`;
+
+const Letter = styled.div`
+  height: 100%;
+  width: 100%;
+  position: absolute;
+  top: 0;
+  left: 0;
+  display: flex;
+  color: white;
+  align-items: center;
+  justify-content: center;
+  font-size: 24px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+`;
+
+const ProjectImage = styled.img`
+  width: 100%;
+  height: 100%;
+`;
+
+const ProjectIcon = styled.div`
+  width: 45px;
+  min-width: 45px;
+  height: 45px;
+  border-radius: 5px;
+  overflow: hidden;
+  position: relative;
+  margin-right: 15px;
+  font-weight: 400;
+  margin-top: 9px;
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: -15px;
+`;
+
+const Warning = styled.span`
+  color: ${(props: { highlight: boolean; makeFlush?: boolean }) =>
+    props.highlight ? "#f5cb42" : ""};
+  margin-left: ${(props: { highlight: boolean; makeFlush?: boolean }) =>
+    props.makeFlush ? "" : "5px"};
+`;
+
+const StyledNewProject = styled.div`
+  min-width: 300px;
+  position: relative;
+`;
+
+const NewProjectSaveButton = styled(SaveButton)`
+  margin-top: 24px;
+`;
+
+const BackButton = styled.div`
+  margin-bottom: 24px;
+  display: flex;
+  width: 36px;
+  cursor: pointer;
+  height: 36px;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const BackButtonImg = styled.img`
+  width: 16px;
+  opacity: 0.75;
+`;

+ 2 - 1
dashboard/src/main/home/new-project/NewProject.tsx

@@ -17,7 +17,7 @@ import Helper from "components/form-components/Helper";
 import TitleSection from "components/TitleSection";
 import WelcomeForm from "./WelcomeForm";
 import { trackCreateNewProject } from "shared/anayltics";
-import { ProjectListType } from "shared/types";
+import { type ProjectListType } from "shared/types";
 
 type ValidationError = {
   hasError: boolean;
@@ -37,6 +37,7 @@ export const NewProjectFC = () => {
   const [name, setName] = useState("");
 
   useEffect(() => {
+    alert("NewProjectFC")
     if (!canCreateProject) {
       pushFiltered("/", []);
     }

+ 39 - 7
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -20,9 +20,9 @@ import Container from "components/porter/Container";
 import Spacer from "components/porter/Spacer";
 import Clusters from "./Clusters";
 import ProjectSectionContainer from "./ProjectSectionContainer";
-import { RouteComponentProps, withRouter } from "react-router";
+import { type RouteComponentProps, withRouter } from "react-router";
 import { getQueryParam, pushFiltered } from "shared/routing";
-import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+import { withAuth, type WithAuthProps } from "shared/auth/AuthorizationHoc";
 import SidebarLink from "./SidebarLink";
 import { overrideInfraTabEnabled } from "utils/infrastructure";
 import ClusterListContainer from "./ClusterListContainer";
@@ -42,7 +42,7 @@ type StateType = {
   pressingCtrl: boolean;
   showTooltip: boolean;
   forceCloseDrawer: boolean;
-  showLinkTooltip: { [linkKey: string]: boolean };
+  showLinkTooltip: Record<string, boolean>;
 };
 
 class Sidebar extends Component<PropsType, StateType> {
@@ -113,13 +113,46 @@ class Sidebar extends Component<PropsType, StateType> {
   };
 
   renderProjectContents = () => {
-    let { currentView } = this.props;
-    let {
+    const { currentView } = this.props;
+    const {
       currentProject,
       user,
       currentCluster,
       hasFinishedOnboarding,
     } = this.context;
+      if (currentCluster.cloud_provider === "Hosted") {
+          return (
+              <ScrollWrapper>
+                  <Spacer y={0.4} />
+                  <NavButton
+                      path="/apps"
+                      active={window.location.pathname.startsWith("/apps")}
+                  >
+                      <Img src={applications} />
+                      Applications
+                  </NavButton>
+                  {this.props.isAuthorized("settings", "", [
+                      "get",
+                      "update",
+                      "delete",
+                  ]) && (
+                      <NavButton path={"/project-settings"}>
+                          <Img src={settings} />
+                          Project settings
+                      </NavButton>
+                  )}
+
+                  {/* Hacky workaround for setting currentCluster with legacy method */}
+                  <Clusters
+                      setWelcome={this.props.setWelcome}
+                      currentView={currentView}
+                      isSelected={false}
+                      forceRefreshClusters={this.props.forceRefreshClusters}
+                      setRefreshClusters={this.props.setRefreshClusters}
+                  />
+              </ScrollWrapper>
+          )
+      }
     if (!currentProject?.simplified_view_enabled) {
       return (
         <ScrollWrapper>
@@ -133,8 +166,7 @@ class Sidebar extends Component<PropsType, StateType> {
             <Img src={rocket} />
             Launch
           </NavButton>
-          {currentProject &&
-            currentProject.managed_infra_enabled &&
+          {currentProject?.managed_infra_enabled &&
             (user?.isPorterUser ||
               overrideInfraTabEnabled({ projectID: currentProject.id })) && (
               <NavButton path={"/infrastructure"}>

+ 5 - 0
dashboard/src/shared/api.tsx

@@ -584,6 +584,10 @@ const createProject = baseApi<{ name: string }, {}>("POST", (pathParams) => {
   return `/api/projects`;
 });
 
+const createHostedProject = baseApi<{ name: string, code: string }, {}>("POST", (pathParams) => {
+    return `/api/projects/hosted`;
+});
+
 const createSubdomain = baseApi<
   {},
   {
@@ -3257,6 +3261,7 @@ export default {
   createPasswordResetVerify,
   createPasswordResetFinalize,
   createProject,
+  createHostedProject,
   // ------------ PORTER APP -----------
   getPorterApps,
   getPorterApp,

+ 9 - 1
dashboard/src/shared/common.tsx

@@ -135,13 +135,21 @@ export const integrationList: any = {
 };
 
 export const isAlphanumeric = (x: string | null) => {
-  let re = /^[a-z0-9-]+$/;
+  const re = /^[a-z0-9-]+$/;
   if (!x || x.length == 0 || x.search(re) === -1) {
     return false;
   }
   return true;
 };
 
+export const isTrueAlphanumeric = (x: string | null) => {
+  const re = /^[a-zA-Z0-9]+$/;
+  if (!x || x.length === 0 || x.search(re) === -1) {
+    return false;
+  }
+  return true;
+};
+
 export const getIgnoreCase = (object: any, key: string) => {
   return object[
     Object.keys(object).find((k) => k.toLowerCase() === key.toLowerCase())

+ 2 - 0
go.mod

@@ -369,3 +369,5 @@ require (
 	sigs.k8s.io/kustomize/kyaml v0.13.9 // indirect
 	sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect
 )
+
+replace github.com/porter-dev/api-contracts => ../api-contracts

+ 16 - 0
internal/models/hosted_code.go

@@ -0,0 +1,16 @@
+package models
+
+import (
+	"gorm.io/gorm"
+)
+
+// HostedCode represents a code that can be used to create a hosted project
+type HostedCode struct {
+	gorm.Model
+
+	Code string `gorm:"primaryKey" json:"code"`
+
+	HostClusterID uint `json:"host_cluster_id"`
+
+	HostProjectID uint `json:"host_project_id"`
+}

+ 5 - 0
internal/repository/deployment_target.go

@@ -1,6 +1,7 @@
 package repository
 
 import (
+	"github.com/google/uuid"
 	"github.com/porter-dev/porter/internal/models"
 )
 
@@ -8,6 +9,10 @@ import (
 type DeploymentTargetRepository interface {
 	// DeploymentTargetBySelectorAndSelectorType finds a deployment target for a projectID and clusterID by its selector and selector type
 	DeploymentTargetBySelectorAndSelectorType(projectID uint, clusterID uint, selector, selectorType string) (*models.DeploymentTarget, error)
+	// DeploymentTargetByID does not scope by projectID and should only be used for internal queries when project id cannot be known.  This should never be exposed to the user.
+	DeploymentTargetByID(id uuid.UUID) (*models.DeploymentTarget, error)
+	// DefaultDeploymentTarget finds the deployment target marked as default for the project id and cluster id
+	DefaultDeploymentTarget(projectID uint, clusterID uint) (*models.DeploymentTarget, error)
 	// List returns all deployment targets for a project
 	List(projectID uint, clusterID uint, preview bool) ([]*models.DeploymentTarget, error)
 	// CreateDeploymentTarget creates a new deployment target

+ 22 - 0
internal/repository/gorm/deployment_target.go

@@ -32,6 +32,28 @@ func (repo *DeploymentTargetRepository) DeploymentTargetBySelectorAndSelectorTyp
 	return deploymentTarget, nil
 }
 
+// DeploymentTargetByID does not scope by projectID and should only be used for internal queries when project id cannot be known.  This should never be exposed to the user.
+func (repo *DeploymentTargetRepository) DeploymentTargetByID(id uuid.UUID) (*models.DeploymentTarget, error) {
+	deploymentTarget := &models.DeploymentTarget{}
+
+	if err := repo.db.Where("id = ?", id).Limit(1).Find(&deploymentTarget).Error; err != nil {
+		return nil, err
+	}
+
+	return deploymentTarget, nil
+}
+
+// DefaultDeploymentTarget finds the default deployment target for a projectID and clusterID
+func (repo *DeploymentTargetRepository) DefaultDeploymentTarget(projectID uint, clusterID uint) (*models.DeploymentTarget, error) {
+	deploymentTarget := &models.DeploymentTarget{}
+
+	if err := repo.db.Where("project_id = ? AND cluster_id = ? AND is_default = TRUE", projectID, clusterID).Limit(1).Find(&deploymentTarget).Error; err != nil {
+		return nil, err
+	}
+
+	return deploymentTarget, nil
+}
+
 // List finds all deployment targets for a given project
 func (repo *DeploymentTargetRepository) List(projectID uint, clusterID uint, preview bool) ([]*models.DeploymentTarget, error) {
 	deploymentTargets := []*models.DeploymentTarget{}

+ 1 - 0
internal/repository/gorm/migrate.go

@@ -65,6 +65,7 @@ func AutoMigrate(db *gorm.DB, debug bool) error {
 		&models.AppRevision{},
 		&models.AppInstance{},
 		&models.DeploymentTarget{},
+		&models.HostedCode{},
 		&models.AppTemplate{},
 		&models.GithubWebhook{},
 		&ints.KubeIntegration{},

+ 5 - 0
internal/repository/test/deployment_target.go

@@ -22,6 +22,11 @@ func (repo *DeploymentTargetRepository) DeploymentTargetBySelectorAndSelectorTyp
 	return nil, errors.New("cannot read database")
 }
 
+// DefaultDeploymentTarget is dummy
+func (repo *DeploymentTargetRepository) DefaultDeploymentTarget(projectID uint, clusterID uint) (*models.DeploymentTarget, error) {
+	return nil, errors.New("cannot read database")
+}
+
 // List returns all deployment targets for a project
 func (repo *DeploymentTargetRepository) List(projectID uint, clusterID uint, preview bool) ([]*models.DeploymentTarget, error) {
 	return nil, errors.New("cannot read database")

+ 1 - 0
zarf/helm/.serverenv

@@ -14,6 +14,7 @@ DB_PORT=5432
 ENABLE_CAPI_PROVISIONER=true
 NATS_URL=nats:4222
 CLUSTER_CONTROL_PLANE_ADDRESS=http://ccp-web:7833
+AUTH_MANAGEMENT_ADDRESS=http://porter-auth-web:8090
 
 # Domain we use to generate custom subdomains from
 APP_ROOT_DOMAIN=withporter.run