浏览代码

updated new project styling

jusrhee 4 年之前
父节点
当前提交
546c4fad71
共有 64 个文件被更改,包括 4107 次插入504 次删除
  1. 1 0
      .github/workflows/production.yaml
  2. 23 1
      .github/workflows/release.yaml
  3. 1 1
      api/server/handlers/release/create_subdomain.go
  4. 32 0
      api/server/handlers/release/get.go
  5. 73 2
      api/server/handlers/release/get_all_pods.go
  6. 17 1
      api/server/handlers/release/get_controllers.go
  7. 4 0
      api/server/handlers/template/get.go
  8. 3 4
      api/server/shared/config/config.go
  9. 4 0
      api/server/shared/config/env/envconfs.go
  10. 4 25
      api/server/shared/config/loader/loader.go
  11. 1 0
      api/types/release.go
  12. 2 5
      build/Dockerfile.osx
  13. 2 4
      build/Dockerfile.win
  14. 18 0
      dashboard/package-lock.json
  15. 3 1
      dashboard/package.json
  16. 8 2
      dashboard/src/components/Button.tsx
  17. 7 1
      dashboard/src/components/SaveButton.tsx
  18. 5 3
      dashboard/src/components/TitleSection.tsx
  19. 2 0
      dashboard/src/components/form-components/InputRow.tsx
  20. 9 7
      dashboard/src/components/repo-selector/RepoList.tsx
  21. 36 0
      dashboard/src/index.html
  22. 10 0
      dashboard/src/main/Main.tsx
  23. 52 158
      dashboard/src/main/home/Home.tsx
  24. 120 0
      dashboard/src/main/home/ModalHandler.tsx
  25. 1 5
      dashboard/src/main/home/NoClusterPlaceholder.tsx
  26. 1 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  27. 1 1
      dashboard/src/main/home/launch/Launch.tsx
  28. 1 1
      dashboard/src/main/home/launch/launch-flow/SourcePage.tsx
  29. 1 1
      dashboard/src/main/home/modals/AccountSettingsModal.tsx
  30. 1 0
      dashboard/src/main/home/new-project/NewProject.tsx
  31. 60 0
      dashboard/src/main/home/onboarding/Onboarding.tsx
  32. 32 0
      dashboard/src/main/home/onboarding/ProvisionerForms.tsx
  33. 46 0
      dashboard/src/main/home/onboarding/Routes.tsx
  34. 148 0
      dashboard/src/main/home/onboarding/components/ProviderSelector.tsx
  35. 120 0
      dashboard/src/main/home/onboarding/state/StateHandler.ts
  36. 242 0
      dashboard/src/main/home/onboarding/state/StepHandler.ts
  37. 58 0
      dashboard/src/main/home/onboarding/state/index.ts
  38. 88 0
      dashboard/src/main/home/onboarding/steps/ConnectRegistry/ConnectRegistry.tsx
  39. 23 0
      dashboard/src/main/home/onboarding/steps/ConnectRegistry/ConnectRegistryWrapper.tsx
  40. 119 0
      dashboard/src/main/home/onboarding/steps/ConnectRegistry/forms/FormFlow.tsx
  41. 220 0
      dashboard/src/main/home/onboarding/steps/ConnectRegistry/forms/_AWSRegistryForm.tsx
  42. 184 0
      dashboard/src/main/home/onboarding/steps/ConnectRegistry/forms/_DORegistryForm.tsx
  43. 215 0
      dashboard/src/main/home/onboarding/steps/ConnectRegistry/forms/_GCPRegistryForm.tsx
  44. 200 0
      dashboard/src/main/home/onboarding/steps/ConnectSource.tsx
  45. 273 0
      dashboard/src/main/home/onboarding/steps/NewProject.tsx
  46. 81 0
      dashboard/src/main/home/onboarding/steps/ProvisionResources/ProvisionResources.tsx
  47. 24 0
      dashboard/src/main/home/onboarding/steps/ProvisionResources/ProvisionResourcesWrapper.tsx
  48. 116 0
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/FormFlow.tsx
  49. 282 0
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_AWSProvsionerForm.tsx
  50. 248 0
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_ConnectExternalCluster.tsx
  51. 273 0
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_DOProvisionerForm.tsx
  52. 271 0
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_GCPProvisionerForm.tsx
  53. 116 0
      dashboard/src/main/home/onboarding/types.ts
  54. 0 32
      dashboard/src/main/home/provisioner/AWSFormSection.tsx
  55. 1 32
      dashboard/src/main/home/provisioner/DOFormSection.tsx
  56. 6 31
      dashboard/src/main/home/provisioner/ExistingClusterSection.tsx
  57. 2 35
      dashboard/src/main/home/provisioner/GCPFormSection.tsx
  58. 8 2
      dashboard/src/main/home/sidebar/ProjectSection.tsx
  59. 12 0
      dashboard/src/shared/api.tsx
  60. 29 1
      dashboard/src/shared/routing.tsx
  61. 153 0
      internal/integrations/powerdns/powerdns.go
  62. 1 4
      internal/kubernetes/agent.go
  63. 6 140
      internal/kubernetes/domain/domain.go
  64. 7 2
      scripts/build/win.sh

+ 1 - 0
.github/workflows/production.yaml

@@ -30,6 +30,7 @@ jobs:
           cat >./dashboard/.env <<EOL
           NODE_ENV=production
           API_SERVER=dashboard.getporter.dev
+          COHERE_API_KEY=${{secrets.COHERE_API_KEY}}
           DISCORD_KEY=${{secrets.DISCORD_KEY}}
           DISCORD_CID=${{secrets.DISCORD_CID}}
           FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}

+ 23 - 1
.github/workflows/release.yaml

@@ -113,7 +113,7 @@ jobs:
           retention-days: 1
   notarize:
     name: Notarize Darwin binaries
-    runs-on: macos-latest
+    runs-on: macos-11
     needs: build
     steps:
       - name: Get tag name
@@ -314,6 +314,28 @@ jobs:
           asset_path: ./release/windows/porter_${{steps.tag_name.outputs.tag}}_Windows_x86_64.zip
           asset_name: porter_${{steps.tag_name.outputs.tag}}_Windows_x86_64.zip
           asset_content_type: application/zip
+      - name: Upload Windows Server Release Asset
+        id: upload-windows-server-release-asset
+        uses: actions/upload-release-asset@v1
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          GITHUB_TAG: ${{ github.ref }}
+        with:
+          upload_url: ${{ steps.create_release.outputs.upload_url }}
+          asset_path: ./release/windows/portersvr_${{steps.tag_name.outputs.tag}}_Windows_x86_64.zip
+          asset_name: portersvr_${{steps.tag_name.outputs.tag}}_Windows_x86_64.zip
+          asset_content_type: application/zip
+      - name: Upload Windows Docker Credential Release Asset
+        id: upload-windows-docker-cred-release-asset
+        uses: actions/upload-release-asset@v1
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          GITHUB_TAG: ${{ github.ref }}
+        with:
+          upload_url: ${{ steps.create_release.outputs.upload_url }}
+          asset_path: ./release/windows/docker-credential-porter_${{steps.tag_name.outputs.tag}}_Windows_x86_64.zip
+          asset_name: docker-credential-porter_${{steps.tag_name.outputs.tag}}_Windows_x86_64.zip
+          asset_content_type: application/zip
       - name: Upload Static Release Asset
         id: upload-static-release-asset
         uses: actions/upload-release-asset@v1

+ 1 - 1
api/server/handlers/release/create_subdomain.go

@@ -67,7 +67,7 @@ func (c *CreateSubdomainHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 
 	_record := domain.DNSRecord(*record)
 
-	err = _record.CreateDomain(c.Config().IngressAgent.Clientset)
+	err = _record.CreateDomain(c.Config().PowerDNSClient)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 32 - 0
api/server/handlers/release/get.go

@@ -3,12 +3,14 @@ package release
 import (
 	"net/http"
 
+	semver "github.com/Masterminds/semver/v3"
 	"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/helm/loader"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/templater/parser"
 	"gorm.io/gorm"
@@ -53,6 +55,36 @@ func (c *ReleaseGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	} else if err != gorm.ErrRecordNotFound {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
+	} else {
+		res.PorterRelease = &types.PorterRelease{}
+	}
+
+	// detect if Porter application chart and attempt to get the latest version
+	// from chart repo
+	cache := c.Config().URLCache
+	chartRepoURL, foundFirst := cache.GetURL(helmRelease.Chart.Metadata.Name)
+
+	if !foundFirst {
+		cache.Update()
+
+		chartRepoURL, _ = cache.GetURL(helmRelease.Chart.Metadata.Name)
+	}
+
+	if chartRepoURL != "" {
+		repoIndex, err := loader.LoadRepoIndexPublic(chartRepoURL)
+
+		if err == nil {
+			porterChart := loader.FindPorterChartInIndexList(repoIndex, res.Chart.Metadata.Name)
+			res.LatestVersion = res.Chart.Metadata.Version
+
+			// set latest version to the greater of porterChart.Versions and res.Chart.Metadata.Version
+			porterChartVersion, porterChartErr := semver.NewVersion(porterChart.Versions[0])
+			currChartVersion, currChartErr := semver.NewVersion(res.Chart.Metadata.Version)
+
+			if currChartErr == nil && porterChartErr == nil && porterChartVersion.GreaterThan(currChartVersion) {
+				res.LatestVersion = porterChart.Versions[0]
+			}
+		}
 	}
 
 	// look for the form using the dynamic client

+ 73 - 2
api/server/handlers/release/get_all_pods.go

@@ -1,6 +1,7 @@
 package release
 
 import (
+	"fmt"
 	"net/http"
 	"strings"
 
@@ -11,6 +12,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/helm/grapher"
+	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/models"
 	"helm.sh/helm/v3/pkg/release"
 	v1 "k8s.io/api/core/v1"
@@ -58,8 +60,38 @@ func (c *GetAllPodsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 		selectors := make([]string, 0)
 
-		for key, val := range selector.MatchLabels {
-			selectors = append(selectors, key+"="+val)
+		if strings.ToLower(controller.Kind) == "cronjob" {
+			// in the case of cronjobs, getting the pod is non-arbitrary. We only get the pod
+			// declared by the manifest, which will have a certain revision attached. But the
+			// label on the pod is the job name, not the cronjob name. So we first find the
+			// list of jobs run by this cronjob, and then get the pods attached to that job.
+			jobLabels := make([]kubernetes.Label, 0)
+
+			for key, val := range selector.MatchLabels {
+				jobLabels = append(jobLabels, kubernetes.Label{
+					Key: key,
+					Val: val,
+				})
+			}
+
+			jobPods, err := getPodsForJobs(agent, helmRelease.Namespace, jobLabels)
+
+			if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+
+			pods = append(pods, jobPods...)
+
+			continue
+		} else if strings.ToLower(controller.Kind) == "job" {
+			// in the case of jobs as the controller, we simply find the job matching the
+			// pod name.
+			selectors = append(selectors, "job-name="+controller.Name)
+		} else {
+			for key, val := range selector.MatchLabels {
+				selectors = append(selectors, key+"="+val)
+			}
 		}
 
 		podList, err := agent.GetPodsByLabel(strings.Join(selectors, ","), helmRelease.Namespace)
@@ -72,5 +104,44 @@ func (c *GetAllPodsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		pods = append(pods, podList.Items...)
 	}
 
+	// we also check for jobs attached to this release
+	labels := getJobLabels(helmRelease)
+
+	labels = append(labels, kubernetes.Label{
+		Key: "helm.sh/revision",
+		Val: fmt.Sprintf("%d", helmRelease.Version),
+	})
+
+	jobPods, err := getPodsForJobs(agent, helmRelease.Namespace, labels)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	pods = append(pods, jobPods...)
+
 	c.WriteResult(w, r, pods)
 }
+
+func getPodsForJobs(agent *kubernetes.Agent, namespace string, labels []kubernetes.Label) ([]v1.Pod, error) {
+	pods := make([]v1.Pod, 0)
+
+	jobs, err := agent.ListJobsByLabel(namespace, labels...)
+
+	if err != nil {
+		return nil, err
+	}
+
+	for _, job := range jobs {
+		podList, err := agent.GetPodsByLabel("job-name="+job.Name, namespace)
+
+		if err != nil {
+			return nil, err
+		}
+
+		pods = append(pods, podList.Items...)
+	}
+
+	return pods, nil
+}

+ 17 - 1
api/server/handlers/release/get_controllers.go

@@ -114,7 +114,23 @@ func getController(controller grapher.Object, agent *kubernetes.Agent) (rc inter
 			return nil, nil, err
 		}
 
-		return obj, obj.Spec.JobTemplate.Spec.Selector, nil
+		res := &metav1.LabelSelector{
+			MatchLabels: make(map[string]string),
+		}
+
+		for key, val := range obj.Spec.JobTemplate.Labels {
+			res.MatchLabels[key] = val
+		}
+
+		return obj, res, nil
+	case "job":
+		obj, err := agent.GetJob(controller)
+
+		if err != nil {
+			return nil, nil, err
+		}
+
+		return obj, obj.Spec.Selector, nil
 	}
 
 	return nil, nil, fmt.Errorf("not a valid controller")

+ 4 - 0
api/server/handlers/template/get.go

@@ -45,6 +45,10 @@ func (t *TemplateGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		version = ""
 	}
 
+	if request.RepoURL == "" {
+		request.RepoURL = t.Config().ServerConf.DefaultApplicationHelmRepoURL
+	}
+
 	chart, err := loader.LoadChartPublic(request.RepoURL, name, version)
 
 	if err != nil {

+ 3 - 4
api/server/shared/config/config.go

@@ -9,6 +9,7 @@ import (
 	"github.com/porter-dev/porter/internal/auth/token"
 	"github.com/porter-dev/porter/internal/billing"
 	"github.com/porter-dev/porter/internal/helm/urlcache"
+	"github.com/porter-dev/porter/internal/integrations/powerdns"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/logger"
 	"github.com/porter-dev/porter/internal/notifier"
@@ -77,10 +78,6 @@ type Config struct {
 	// jobs
 	ProvisionerAgent *kubernetes.Agent
 
-	// IngressAgent is the kubernetes client responsible for creating new ingress
-	// resources
-	IngressAgent *kubernetes.Agent
-
 	// DB is the gorm DB instance
 	DB *gorm.DB
 
@@ -89,6 +86,8 @@ type Config struct {
 
 	// BillingManager manages billing for Porter instances with billing enabled
 	BillingManager billing.BillingManager
+	// PowerDNSClient is a client for PowerDNS, if the Porter instance supports vanity URLs
+	PowerDNSClient *powerdns.Client
 }
 
 type ConfigLoader interface {

+ 4 - 0
api/server/shared/config/env/envconfs.go

@@ -58,6 +58,10 @@ type ServerConf struct {
 	ProvisionerImagePullSecret string `env:"PROV_IMAGE_PULL_SECRET"`
 	SegmentClientKey           string `env:"SEGMENT_CLIENT_KEY"`
 
+	// PowerDNS client API key and the host of the PowerDNS API server
+	PowerDNSAPIServerURL string `env:"POWER_DNS_API_SERVER_URL"`
+	PowerDNSAPIKey       string `env:"POWER_DNS_API_KEY"`
+
 	// Email for an admin user. On a self-hosted instance of Porter, the
 	// admin user is the only user that can log in and register. After the admin
 	// user has logged in, registration is turned off.

+ 4 - 25
api/server/shared/config/loader/loader.go

@@ -16,6 +16,7 @@ import (
 	"github.com/porter-dev/porter/internal/auth/token"
 	"github.com/porter-dev/porter/internal/billing"
 	"github.com/porter-dev/porter/internal/helm/urlcache"
+	"github.com/porter-dev/porter/internal/integrations/powerdns"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes/local"
 	"github.com/porter-dev/porter/internal/notifier"
@@ -194,16 +195,12 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 		res.Metadata.Provisioning = true
 	}
 
-	ingressAgent, err := getIngressAgent(sc)
+	res.AnalyticsClient = analytics.InitializeAnalyticsSegmentClient(sc.SegmentClientKey, res.Logger)
 
-	if err != nil {
-		return nil, err
+	if sc.PowerDNSAPIKey != "" && sc.PowerDNSAPIServerURL != "" {
+		res.PowerDNSClient = powerdns.NewClient(sc.PowerDNSAPIServerURL, sc.PowerDNSAPIKey, sc.AppRootDomain)
 	}
 
-	res.IngressAgent = ingressAgent
-
-	res.AnalyticsClient = analytics.InitializeAnalyticsSegmentClient(sc.SegmentClientKey, res.Logger)
-
 	return res, nil
 }
 
@@ -224,21 +221,3 @@ func getProvisionerAgent(sc *env.ServerConf) (*kubernetes.Agent, error) {
 
 	return agent, nil
 }
-
-func getIngressAgent(sc *env.ServerConf) (*kubernetes.Agent, error) {
-	if sc.IngressCluster == "kubeconfig" && sc.SelfKubeconfig != "" {
-		agent, err := local.GetSelfAgentFromFileConfig(sc.SelfKubeconfig)
-
-		if err != nil {
-			return nil, fmt.Errorf("could not get in-cluster agent: %v", err)
-		}
-
-		return agent, nil
-	} else if sc.ProvisionerCluster == "kubeconfig" {
-		return nil, fmt.Errorf(`"kubeconfig" cluster option requires path to kubeconfig`)
-	}
-
-	agent, _ := kubernetes.GetAgentInClusterConfig()
-
-	return agent, nil
-}

+ 1 - 0
api/types/release.go

@@ -17,6 +17,7 @@ type Release struct {
 type PorterRelease struct {
 	ID              uint             `json:"id"`
 	WebhookToken    string           `json:"webhook_token"`
+	LatestVersion   string           `json:"latest_version"`
 	GitActionConfig *GitActionConfig `json:"git_action_config,omitempty"`
 	ImageRepoURI    string           `json:"image_repo_uri"`
 }

+ 2 - 5
build/Dockerfile.osx

@@ -1,17 +1,14 @@
-ARG GO_VERSION=1.13.15
+ARG GO_VERSION=1.16
 
-FROM dockercore/golang-cross:${GO_VERSION}
+FROM golang:${GO_VERSION}
 
 RUN apt-get update && apt-get install -y zip unzip
 
 WORKDIR /go/src/github.com/docker/cli
 COPY    . .
 
-ENV CGO_ENABLED 1
 ENV GOOS darwin
 ENV GOARCH amd64
-ENV CC o64-clang
-ENV CXX o64-clang++
 
 RUN chmod +x ./scripts/build/osx.sh
 

+ 2 - 4
build/Dockerfile.win

@@ -1,14 +1,12 @@
-ARG GO_VERSION=1.13.15
+ARG GO_VERSION=1.16
 
-FROM	dockercore/golang-cross:${GO_VERSION}
+FROM golang:${GO_VERSION}
 
 RUN apt-get update && apt-get install -y zip unzip
 
 WORKDIR /go/src/github.com/docker/cli
 COPY    . .
 
-ENV CC x86_64-w64-mingw32-gcc
-ENV CGO_ENABLED 1
 ENV GOOS windows 
 ENV GOARCH amd64
 

+ 18 - 0
dashboard/package-lock.json

@@ -5073,6 +5073,11 @@
       "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz",
       "integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA=="
     },
+    "cohere-js": {
+      "version": "1.0.19",
+      "resolved": "https://registry.npmjs.org/cohere-js/-/cohere-js-1.0.19.tgz",
+      "integrity": "sha512-2XVX2LUKHjbJ4GCsnizXnAVHZfq9RM1RmHl8zE4G2ORdXmDpzSx5i0UIj/0GZ3AwjKIlYsrGA4kdCGT+WapjPQ=="
+    },
     "collection-visit": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz",
@@ -9241,6 +9246,11 @@
         "ipaddr.js": "1.9.1"
       }
     },
+    "proxy-compare": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-2.0.2.tgz",
+      "integrity": "sha512-3qUXJBariEj3eO90M3Rgqq3+/P5Efl0t/dl9g/1uVzIQmO3M+ql4hvNH3mYdu8H+1zcKv07YvL55tsY74jmH1A=="
+    },
     "prr": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
@@ -11522,6 +11532,14 @@
       "integrity": "sha512-gTpR5XQNKFwOd4clxfnhaqvfqMpqEwr4tOtCyz4MtYZX2JYhfr1JvBFKdS+7K/9rfpZR3VLX+YWBbKoxCgS43Q==",
       "dev": true
     },
+    "valtio": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/valtio/-/valtio-1.2.4.tgz",
+      "integrity": "sha512-FipYZHGJXsSKObKNGOHwqqiA6T84T7LHhrPfM7ptt3e2uFao4djD5/u4JEb/z2O14fv1CFxIO05UWCuk3VT/qg==",
+      "requires": {
+        "proxy-compare": "2.0.2"
+      }
+    },
     "value-equal": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz",

+ 3 - 1
dashboard/package.json

@@ -22,6 +22,7 @@
     "axios": "^0.20.0",
     "brace": "^0.11.1",
     "clipboard": "^2.0.8",
+    "cohere-js": "^1.0.19",
     "core-js": "^3.16.1",
     "d3-array": "^2.11.0",
     "d3-time-format": "^3.0.0",
@@ -45,7 +46,8 @@
     "regenerator-runtime": "^0.13.9",
     "semver": "^7.3.5",
     "stacktrace-js": "^2.0.2",
-    "styled-components": "^5.2.0"
+    "styled-components": "^5.2.0",
+    "valtio": "^1.2.4"
   },
   "scripts": {
     "test": "echo \"Error: no test specified\" && exit 1",

+ 8 - 2
dashboard/src/components/Button.tsx

@@ -5,11 +5,17 @@ interface Props {
   disabled?: boolean;
   children: React.ReactNode;
   onClick: () => void;
+  className?: string;
 }
 
-const Button: React.FC<Props> = ({ children, disabled, onClick }) => {
+const Button: React.FC<Props> = ({
+  children,
+  disabled,
+  onClick,
+  className,
+}) => {
   return (
-    <ButtonWrapper disabled={disabled} onClick={onClick}>
+    <ButtonWrapper className={className} disabled={disabled} onClick={onClick}>
       {children}
     </ButtonWrapper>
   );

+ 7 - 1
dashboard/src/components/SaveButton.tsx

@@ -16,6 +16,9 @@ type Props = {
   makeFlush?: boolean;
   clearPosition?: boolean;
   statusPosition?: "right" | "left";
+  // Provide the classname to modify styles from other components
+  className?: string;
+  successText?: string;
 };
 
 const SaveButton: React.FC<Props> = (props) => {
@@ -25,7 +28,9 @@ const SaveButton: React.FC<Props> = (props) => {
         return (
           <StatusWrapper position={props.statusPosition} successful={true}>
             <i className="material-icons">done</i>
-            <StatusTextWrapper>Successfully updated</StatusTextWrapper>
+            <StatusTextWrapper>
+              {props?.successText || "Successfully updated"}
+            </StatusTextWrapper>
           </StatusWrapper>
         );
       } else if (props.status === "loading") {
@@ -65,6 +70,7 @@ const SaveButton: React.FC<Props> = (props) => {
     <ButtonWrapper
       makeFlush={props.makeFlush}
       clearPosition={props.clearPosition}
+      className={props.className}
     >
       {props.statusPosition !== "right" && <div>{renderStatus()}</div>}
       <Button

+ 5 - 3
dashboard/src/components/TitleSection.tsx

@@ -6,6 +6,7 @@ interface Props {
   icon?: any;
   iconWidth?: string;
   capitalize?: boolean;
+  className?: string;
   handleNavBack?: () => void;
 }
 
@@ -15,9 +16,10 @@ const TitleSection: React.FC<Props> = ({
   iconWidth,
   capitalize,
   handleNavBack,
+  className,
 }) => {
   return (
-    <StyledTitleSection>
+    <StyledTitleSection className={className}>
       {handleNavBack && (
         <BackButton>
           <i className="material-icons" onClick={handleNavBack}>
@@ -36,8 +38,8 @@ export default TitleSection;
 const BackButton = styled.div`
   > i {
     cursor: pointer;
-    font-size 24px;
-    color: #969Fbbaa;
+    font-size: 24px;
+    color: #969fbbaa;
     margin-right: 10px;
     padding: 3px;
     margin-left: 0px;

+ 2 - 0
dashboard/src/components/form-components/InputRow.tsx

@@ -14,6 +14,7 @@ type PropsType = {
   disabled?: boolean;
   isRequired?: boolean;
   className?: string;
+  maxLength?: number;
 };
 
 type StateType = {
@@ -74,6 +75,7 @@ export default class InputRow extends Component<PropsType, StateType> {
             type={type}
             value={value}
             onChange={this.handleChange}
+            maxLength={this.props.maxLength}
           />
           {unit ? <Unit>{unit}</Unit> : null}
         </InputWrapper>

+ 9 - 7
dashboard/src/components/repo-selector/RepoList.tsx

@@ -151,13 +151,15 @@ const RepoList: React.FC<Props> = ({
       }
 
       if (accessData.accounts?.length === 0) {
-        <LoadingWrapper>
-          No connected Github repos found. You can
-          <A href={"/api/integrations/github-app/install"}>
-            Install Porter in more repositories
-          </A>
-          .
-        </LoadingWrapper>;
+        return (
+          <LoadingWrapper>
+            No connected Github repos found. You can
+            <A href={"/api/integrations/github-app/install"}>
+              Install Porter in more repositories
+            </A>
+            .
+          </LoadingWrapper>
+        );
       }
     }
 

+ 36 - 0
dashboard/src/index.html

@@ -3,6 +3,42 @@
   <head>
     <title>Porter | Dashboard</title>
 
+    <script>
+      !(function () {
+        var e = (window.Cohere = window.Cohere || []);
+        if (e.invoked) console.error("Tried to load Cohere twice");
+        else {
+          (e.invoked = !0),
+            (e.snippet = "0.2"),
+            (e.methods = [
+              "init",
+              "identify",
+              "stop",
+              "showCode",
+              "getSessionUrl",
+              "makeCall",
+              "addCallStatusListener",
+              "removeCallStatusListener",
+              "widget",
+            ]),
+            e.methods.forEach(function (o) {
+              e[o] = function () {
+                var t = Array.prototype.slice.call(arguments);
+                t.unshift(o), e.push(t);
+              };
+            });
+          var o = document.createElement("script");
+          (o.type = "text/javascript"),
+            (o.async = !0),
+            (o.src = "https://static.cohere.so/main.js"),
+            (o.crossOrigin = "anonymous");
+          var t = document.getElementsByTagName("script")[0];
+          t.parentNode.insertBefore(o, t);
+        }
+      })();
+      window.Cohere.init("_A-2HNgriISqaQq4yzTxM8V-");
+    </script>
+
     <script>
       !(function () {
         var analytics = (window.analytics = window.analytics || []);

+ 10 - 0
dashboard/src/main/Main.tsx

@@ -3,6 +3,9 @@ import { Route, Redirect, Switch } from "react-router-dom";
 
 import api from "shared/api";
 import { Context } from "shared/Context";
+import Cohere from "cohere-js";
+
+Cohere.init(process.env.COHERE_API_KEY);
 
 import ResetPasswordInit from "./auth/ResetPasswordInit";
 import ResetPasswordFinalize from "./auth/ResetPasswordFinalize";
@@ -42,6 +45,13 @@ export default class Main extends Component<PropsType, StateType> {
       .checkAuth("", {}, {})
       .then((res) => {
         if (res && res?.data) {
+          Cohere.identify(
+            res?.data?.id, 
+            {
+              displayName: res?.data?.email,
+              email: res?.data?.email, 
+            }
+          );
           setUser(res?.data?.id, res?.data?.email);
           this.setState({
             isLoggedIn: true,

+ 52 - 158
dashboard/src/main/home/Home.tsx

@@ -1,5 +1,5 @@
 import React, { Component } from "react";
-import { RouteComponentProps, withRouter } from "react-router";
+import { RouteComponentProps, Switch, withRouter } from "react-router";
 import styled from "styled-components";
 
 import api from "shared/api";
@@ -15,24 +15,18 @@ import Dashboard from "./dashboard/Dashboard";
 import WelcomeForm from "./WelcomeForm";
 import Integrations from "./integrations/Integrations";
 import Templates from "./launch/Launch";
-import ClusterInstructionsModal from "./modals/ClusterInstructionsModal";
-import IntegrationsInstructionsModal from "./modals/IntegrationsInstructionsModal";
-import IntegrationsModal from "./modals/IntegrationsModal";
-import Modal from "./modals/Modal";
-import UpdateClusterModal from "./modals/UpdateClusterModal";
-import NamespaceModal from "./modals/NamespaceModal";
+
 import Navbar from "./navbar/Navbar";
-import NewProject from "./new-project/NewProject";
 import ProjectSettings from "./project-settings/ProjectSettings";
 import Sidebar from "./sidebar/Sidebar";
 import PageNotFound from "components/PageNotFound";
-import DeleteNamespaceModal from "./modals/DeleteNamespaceModal";
+
 import { fakeGuardedRoute } from "shared/auth/RouteGuard";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
-import EditInviteOrCollaboratorModal from "./modals/EditInviteOrCollaboratorModal";
-import AccountSettingsModal from "./modals/AccountSettingsModal";
 import discordLogo from "../../assets/discord.svg";
-import UsageWarningModal from "./modals/UsageWarningModal";
+import Onboarding from "./onboarding/Onboarding";
+import ModalHandler from "./ModalHandler";
+
 // Guarded components
 const GuardedProjectSettings = fakeGuardedRoute("settings", "", [
   "get",
@@ -141,7 +135,7 @@ class Home extends Component<PropsType, StateType> {
       .then((res) => {
         if (res.data) {
           if (res.data.length === 0) {
-            pushFiltered(this.props, "/new-project", ["project_id"]);
+            pushFiltered(this.props, "/onboarding/new-project", ["project_id"]);
           } else if (res.data.length > 0 && !currentProject) {
             setProjects(res.data);
 
@@ -356,7 +350,7 @@ class Home extends Component<PropsType, StateType> {
   renderContents = () => {
     let currentView = this.props.currentRoute;
 
-    if (this.context.currentProject && currentView !== "new-project") {
+    if (this.context.currentProject && currentView !== "onboarding") {
       if (
         currentView === "cluster-dashboard" ||
         currentView === "applications" ||
@@ -381,46 +375,8 @@ class Home extends Component<PropsType, StateType> {
         return <GuardedProjectSettings />;
       }
       return <Templates />;
-    } else if (currentView === "new-project") {
-      return <NewProject />;
-    }
-  };
-
-  renderSidebar = () => {
-    if (this.context.projects.length > 0) {
-      return (
-        <Sidebar
-          key="sidebar"
-          forceSidebar={this.state.forceSidebar}
-          setWelcome={(x: boolean) => this.setState({ showWelcome: x })}
-          currentView={this.props.currentRoute}
-          forceRefreshClusters={this.state.forceRefreshClusters}
-          setRefreshClusters={(x: boolean) =>
-            this.setState({ forceRefreshClusters: x })
-          }
-        />
-      );
-    } else {
-      return (
-        <>
-          <DiscordButton href="https://discord.gg/34n7NN7FJ7" target="_blank">
-            <Icon src={discordLogo} />
-            Join Our Discord
-          </DiscordButton>
-          {this.state.showWelcomeForm &&
-            localStorage.getItem("welcomed") != "true" && (
-              <>
-                <WelcomeForm
-                  closeForm={() => this.setState({ showWelcomeForm: false })}
-                />
-                <Navbar
-                  logOut={this.props.logOut}
-                  currentView={this.props.currentRoute} // For form feedback
-                />
-              </>
-            )}
-        </>
-      );
+    } else if (currentView === "onboarding") {
+      return <Onboarding />;
     }
   };
 
@@ -435,7 +391,9 @@ class Home extends Component<PropsType, StateType> {
             setCurrentProject(res.data[0]);
           } else {
             setCurrentProject(null, () =>
-              pushFiltered(this.props, "/new-project", ["project_id"])
+              pushFiltered(this.props, "/onboarding/new-project", [
+                "project_id",
+              ])
             );
           }
           this.context.setCurrentModal(null, null);
@@ -488,112 +446,15 @@ class Home extends Component<PropsType, StateType> {
       setCurrentModal,
       currentProject,
       currentOverlay,
-      setCurrentOverlay,
+      projects,
     } = this.context;
 
+    const { cluster } = this.props.match.params as any;
     return (
       <StyledHome>
-        {currentModal === "ClusterInstructionsModal" && (
-          <Modal
-            onRequestClose={() => setCurrentModal(null, null)}
-            width="760px"
-            height="650px"
-            title="Connecting to an Existing Cluster"
-          >
-            <ClusterInstructionsModal />
-          </Modal>
-        )}
-
-        {/* We should be careful, as this component is named Update but is for deletion */}
-        {this.props.isAuthorized("cluster", "", ["get", "delete"]) &&
-          currentModal === "UpdateClusterModal" && (
-            <Modal
-              onRequestClose={() => setCurrentModal(null, null)}
-              width="565px"
-              height="275px"
-              title="Cluster Settings"
-            >
-              <UpdateClusterModal
-                setRefreshClusters={(x: boolean) =>
-                  this.setState({ forceRefreshClusters: x })
-                }
-              />
-            </Modal>
-          )}
-        {currentModal === "IntegrationsModal" && (
-          <Modal
-            onRequestClose={() => setCurrentModal(null, null)}
-            width="760px"
-            height="380px"
-            title="Add a New Integration"
-          >
-            <IntegrationsModal />
-          </Modal>
-        )}
-        {currentModal === "IntegrationsInstructionsModal" && (
-          <Modal
-            onRequestClose={() => setCurrentModal(null, null)}
-            width="760px"
-            height="650px"
-            title="Connecting to an Image Registry"
-          >
-            <IntegrationsInstructionsModal />
-          </Modal>
-        )}
-        {this.props.isAuthorized("namespace", "", ["get", "create"]) &&
-          currentModal === "NamespaceModal" && (
-            <Modal
-              onRequestClose={() => setCurrentModal(null, null)}
-              width="600px"
-              height="220px"
-              title="Add Namespace"
-            >
-              <NamespaceModal />
-            </Modal>
-          )}
-        {this.props.isAuthorized("namespace", "", ["get", "delete"]) &&
-          currentModal === "DeleteNamespaceModal" && (
-            <Modal
-              onRequestClose={() => setCurrentModal(null, null)}
-              width="700px"
-              height="280px"
-              title="Delete Namespace"
-            >
-              <DeleteNamespaceModal />
-            </Modal>
-          )}
-
-        {currentModal === "EditInviteOrCollaboratorModal" && (
-          <Modal
-            onRequestClose={() => setCurrentModal(null, null)}
-            width="600px"
-            height="250px"
-          >
-            <EditInviteOrCollaboratorModal />
-          </Modal>
-        )}
-        {currentModal === "AccountSettingsModal" && (
-          <Modal
-            onRequestClose={() => setCurrentModal(null, null)}
-            width="760px"
-            height="440px"
-            title="Account Settings"
-          >
-            <AccountSettingsModal />
-          </Modal>
-        )}
-
-        {currentModal === "UsageWarningModal" && (
-          <Modal
-            onRequestClose={() => setCurrentModal(null, null)}
-            width="760px"
-            height="530px"
-            title="Usage Warning"
-          >
-            <UsageWarningModal />
-          </Modal>
-        )}
-
+        <ModalHandler
+          setRefreshClusters={(x) => this.setState({ forceRefreshClusters: x })}
+        />
         {currentOverlay && (
           <ConfirmOverlay
             show={true}
@@ -603,7 +464,40 @@ class Home extends Component<PropsType, StateType> {
           />
         )}
 
-        {this.renderSidebar()}
+        {/* Render sidebar when there's at least one project */}
+        {projects?.length > 0 && cluster !== "new-project" ? (
+          <Sidebar
+            key="sidebar"
+            forceSidebar={this.state.forceSidebar}
+            setWelcome={(x: boolean) => this.setState({ showWelcome: x })}
+            currentView={this.props.currentRoute}
+            forceRefreshClusters={this.state.forceRefreshClusters}
+            setRefreshClusters={(x: boolean) =>
+              this.setState({ forceRefreshClusters: x })
+            }
+          />
+        ) : (
+          <>
+            <DiscordButton href="https://discord.gg/34n7NN7FJ7" target="_blank">
+              <Icon src={discordLogo} />
+              Join Our Discord
+            </DiscordButton>
+            {/* This should only be shown on the first render of the app */}
+            {this.state.showWelcomeForm &&
+              localStorage.getItem("welcomed") != "true" &&
+              projects?.length === 0 && (
+                <>
+                  <WelcomeForm
+                    closeForm={() => this.setState({ showWelcomeForm: false })}
+                  />
+                  <Navbar
+                    logOut={this.props.logOut}
+                    currentView={this.props.currentRoute} // For form feedback
+                  />
+                </>
+              )}
+          </>
+        )}
 
         <ViewWrapper>
           <Navbar

+ 120 - 0
dashboard/src/main/home/ModalHandler.tsx

@@ -0,0 +1,120 @@
+import React, { useContext, useEffect } from "react";
+import useAuth from "shared/auth/useAuth";
+import { Context } from "shared/Context";
+import Modal from "./modals/Modal";
+import ClusterInstructionsModal from "./modals/ClusterInstructionsModal";
+import IntegrationsInstructionsModal from "./modals/IntegrationsInstructionsModal";
+import IntegrationsModal from "./modals/IntegrationsModal";
+import UpdateClusterModal from "./modals/UpdateClusterModal";
+import NamespaceModal from "./modals/NamespaceModal";
+import DeleteNamespaceModal from "./modals/DeleteNamespaceModal";
+import EditInviteOrCollaboratorModal from "./modals/EditInviteOrCollaboratorModal";
+import AccountSettingsModal from "./modals/AccountSettingsModal";
+
+const ModalHandler: React.FC<{
+  setRefreshClusters: (x: boolean) => void;
+}> = ({ setRefreshClusters }) => {
+  const [isAuth] = useAuth();
+  const { currentModal, setCurrentModal, currentProject } = useContext(Context);
+
+  useEffect(() => {
+    const projectOnboarding = localStorage.getItem(
+      `onboarding-${currentProject?.id}`
+    );
+    const parsedProjectOnboarding = JSON.parse(projectOnboarding);
+    if (parsedProjectOnboarding?.StepHandler?.finishedOnboarding === false) {
+      setCurrentModal("RedirectToOnboarding");
+    }
+  }, [currentProject?.id]);
+
+  return (
+    <>
+      {currentModal === "RedirectToOnboarding" && (
+        <a href="/onboarding/new-project"></a>
+      )}
+
+      {currentModal === "ClusterInstructionsModal" && (
+        <Modal
+          onRequestClose={() => setCurrentModal(null, null)}
+          width="760px"
+          height="650px"
+        >
+          <ClusterInstructionsModal />
+        </Modal>
+      )}
+
+      {/* We should be careful, as this component is named Update but is for deletion */}
+      {isAuth("cluster", "", ["get", "delete"]) &&
+        currentModal === "UpdateClusterModal" && (
+          <Modal
+            onRequestClose={() => setCurrentModal(null, null)}
+            width="565px"
+            height="275px"
+          >
+            <UpdateClusterModal
+              setRefreshClusters={(x: boolean) => setRefreshClusters(x)}
+            />
+          </Modal>
+        )}
+      {currentModal === "IntegrationsModal" && (
+        <Modal
+          onRequestClose={() => setCurrentModal(null, null)}
+          width="760px"
+          height="725px"
+        >
+          <IntegrationsModal />
+        </Modal>
+      )}
+      {currentModal === "IntegrationsInstructionsModal" && (
+        <Modal
+          onRequestClose={() => setCurrentModal(null, null)}
+          width="760px"
+          height="650px"
+        >
+          <IntegrationsInstructionsModal />
+        </Modal>
+      )}
+      {isAuth("namespace", "", ["get", "create"]) &&
+        currentModal === "NamespaceModal" && (
+          <Modal
+            onRequestClose={() => setCurrentModal(null, null)}
+            width="600px"
+            height="220px"
+          >
+            <NamespaceModal />
+          </Modal>
+        )}
+      {isAuth("namespace", "", ["get", "delete"]) &&
+        currentModal === "DeleteNamespaceModal" && (
+          <Modal
+            onRequestClose={() => setCurrentModal(null, null)}
+            width="700px"
+            height="280px"
+          >
+            <DeleteNamespaceModal />
+          </Modal>
+        )}
+
+      {currentModal === "EditInviteOrCollaboratorModal" && (
+        <Modal
+          onRequestClose={() => setCurrentModal(null, null)}
+          width="600px"
+          height="250px"
+        >
+          <EditInviteOrCollaboratorModal />
+        </Modal>
+      )}
+      {currentModal === "AccountSettingsModal" && (
+        <Modal
+          onRequestClose={() => setCurrentModal(null, null)}
+          width="760px"
+          height="440px"
+        >
+          <AccountSettingsModal />
+        </Modal>
+      )}
+    </>
+  );
+};
+
+export default ModalHandler;

+ 1 - 5
dashboard/src/main/home/NoClusterPlaceholder.tsx

@@ -25,11 +25,7 @@ class NoClusterPlaceholder extends Component<PropsType, StateType> {
         <br />
         <br />
         1. If you're deploying from a repo{" "}
-        <A
-          onClick={() =>
-            window.open(`/api/oauth/projects/${currentProject.id}/github`)
-          }
-        >
+        <A href={"/api/integrations/github-app/oauth"}>
           link your GitHub account
         </A>
         <br />

+ 1 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -608,14 +608,13 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
       await api.upgradeChartValues(
         "<token>",
         {
-          namespace: currentChart.namespace,
-          storage: StorageType.Secret,
           values: valuesYaml,
           version: version,
         },
         {
           id: this.context.currentProject.id,
           name: currentChart.name,
+          namespace: currentChart.namespace,
           cluster_id: this.context.currentCluster.id,
         }
       );

+ 1 - 1
dashboard/src/main/home/launch/Launch.tsx

@@ -325,7 +325,7 @@ class Templates extends Component<PropsType, StateType> {
         <TemplatesWrapper>
           <TitleSection>
             Launch
-            <a href="https://docs.porter.run/docs/addons" target="_blank">
+            <a href="https://docs.porter.run/docs/applications" target="_blank">
               <i className="material-icons">help_outline</i>
             </a>
           </TitleSection>

+ 1 - 1
dashboard/src/main/home/launch/launch-flow/SourcePage.tsx

@@ -275,7 +275,7 @@ class SourcePage extends Component<PropsType, StateType> {
         <Helper>
           Learn more about
           <Highlight
-            href="https://docs.porter.run/docs/addons"
+            href="https://docs.porter.run/docs/applications"
             target="_blank"
           >
             deploying services to Porter

+ 1 - 1
dashboard/src/main/home/modals/AccountSettingsModal.tsx

@@ -69,7 +69,7 @@ const AccountSettingsModal = () => {
           )}
 
           {/* Will be styled (and show what account is connected) later */}
-          {!accessError && accessData.accounts?.length >= 0 && (
+          {!accessError && accessData.username && (
             <Placeholder>
               <User>
                 You are currently authorized as <B>{accessData.username}</B> and

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

@@ -148,5 +148,6 @@ const StyledNewProject = styled.div`
   width: calc(90% - 130px);
   min-width: 300px;
   position: relative;
+  background: red;
   margin-top: calc(50vh - 340px);
 `;

+ 60 - 0
dashboard/src/main/home/onboarding/Onboarding.tsx

@@ -0,0 +1,60 @@
+import React, { useContext, useEffect } from "react";
+import { useLocation } from "react-router";
+import { Context } from "shared/Context";
+import { useRouting } from "shared/routing";
+import styled from "styled-components";
+import { useSnapshot } from "valtio";
+import { devtools } from "valtio/utils";
+import Routes from "./Routes";
+import { OFState } from "./state";
+import { useSteps } from "./state/StepHandler";
+
+const Onboarding = () => {
+  useSteps();
+
+  useEffect(() => {
+    let sub = devtools(OFState, "Onboarding flow state");
+    return () => {
+      sub();
+    };
+  }, []);
+
+  // useEffect(() => {
+  //   OFState.actions.initializeState(context.currentProject?.id);
+  //   return () => {
+  //     OFState.actions.clearState();
+  //   };
+  // }, [context.currentProject?.id]);
+
+  // useEffect(() => {
+  //   if (snap.StepHandler.finishedOnboarding) {
+  //     OFState.actions.clearState();
+  //     pushFiltered("/dashboard", []);
+  //   } else if (snap.StepHandler?.currentStep?.url !== location.pathname) {
+  //     pushFiltered(snap.StepHandler.currentStep.url, []);
+  //   }
+  // }, [
+  //   location.pathname,
+  //   snap.StepHandler?.currentStep?.url,
+  //   snap.StepHandler?.finishedOnboarding,
+  // ]);
+
+  return (
+    <StyledOnboarding>
+      <Routes />
+    </StyledOnboarding>
+  );
+};
+
+export default Onboarding;
+
+const StyledOnboarding = styled.div`
+  max-width: 700px;
+  width: 50%;
+  display: flex;
+  align-items: center;
+  margin-top: -6%;
+  padding-bottom: 5%;
+  min-width: 300px;
+  position: relative;
+`;

+ 32 - 0
dashboard/src/main/home/onboarding/ProvisionerForms.tsx

@@ -0,0 +1,32 @@
+import Helper from "components/form-components/Helper";
+import TitleSection from "components/TitleSection";
+import React, { useContext } from "react";
+import { Context } from "shared/Context";
+import styled from "styled-components";
+import { useSnapshot } from "valtio";
+import ProvisionerSettings from "../provisioner/ProvisionerSettings";
+import { OFState } from "./state";
+
+const ProvisionerForms = () => {
+  const snap = useSnapshot(OFState);
+  const { capabilities } = useContext(Context);
+  return (
+    <>
+      <TitleSection>Getting Started</TitleSection>
+      <Subtitle>Step 2 of 2</Subtitle>
+      <Helper>Provision a new cluster through us or link one later!</Helper>
+      <ProvisionerSettings
+        isInNewProject={true}
+        projectName={snap.StateHandler.project.name}
+        provisioner={capabilities.provisioner}
+      />
+    </>
+  );
+};
+
+export default ProvisionerForms;
+
+const Subtitle = styled(TitleSection)`
+  font-size: 16px;
+  margin-top: 16px;
+`;

+ 46 - 0
dashboard/src/main/home/onboarding/Routes.tsx

@@ -0,0 +1,46 @@
+import React from "react";
+import { Route, Switch } from "react-router";
+import { useSnapshot } from "valtio";
+import { OFState } from "./state";
+import ConnectRegistry from "./steps/ConnectRegistry/ConnectRegistry";
+import ConnectRegistryWrapper from "./steps/ConnectRegistry/ConnectRegistryWrapper";
+import ConnectSource from "./steps/ConnectSource";
+import { NewProjectFC } from "./steps/NewProject";
+import ProvisionResources from "./steps/ProvisionResources/ProvisionResources";
+import ProvisionResourcesWrapper from "./steps/ProvisionResources/ProvisionResourcesWrapper";
+
+const handleContinue = (data?: any) => {
+  OFState.actions.nextStep("continue", data);
+};
+
+const handleSkip = () => {
+  OFState.actions.nextStep("skip");
+};
+
+export const Routes = () => {
+  const snap = useSnapshot(OFState);
+  return (
+    <>
+      <Switch>
+        <Route path={`/onboarding/new-project`}>
+          <NewProjectFC
+            onSuccess={(data) => OFState.actions.nextStep("continue", data)}
+          />
+        </Route>
+        <Route path={`/onboarding/source`}>
+          <ConnectSource
+            onSuccess={(data) => OFState.actions.nextStep("continue", data)}
+          />
+        </Route>
+        <Route path={["/onboarding/registry/:step?"]}>
+          <ConnectRegistryWrapper />
+        </Route>
+        <Route path={[`/onboarding/provision/:step?`]}>
+          <ProvisionResourcesWrapper />
+        </Route>
+      </Switch>
+    </>
+  );
+};
+
+export default Routes;

+ 148 - 0
dashboard/src/main/home/onboarding/components/ProviderSelector.tsx

@@ -0,0 +1,148 @@
+import React from "react";
+import { integrationList } from "shared/common";
+import styled from "styled-components";
+import { SupportedProviders } from "../types";
+
+export type ProviderSelectorProps = {
+  selectProvider: (
+    provider: SupportedProviders | (SupportedProviders | "external")
+  ) => void;
+  enableExternal?: boolean;
+};
+
+const providers: SupportedProviders[] = ["aws", "gcp", "do"];
+
+const ProviderSelector: React.FC<ProviderSelectorProps> = ({
+  selectProvider,
+  enableExternal,
+}) => {
+  return (
+    <>
+      <BlockList>
+        {providers.map((provider, i: number) => {
+          let providerInfo = integrationList[provider];
+          return (
+            <Block
+              key={i}
+              onClick={() => {
+                selectProvider(provider);
+              }}
+            >
+              <Icon src={providerInfo.icon} />
+              <BlockTitle>{providerInfo.label}</BlockTitle>
+              <CostSection
+                onClick={(e) => {
+                  e.stopPropagation();
+                  selectProvider(provider);
+                }}
+              ></CostSection>
+              <BlockDescription>Hosted in your own cloud.</BlockDescription>
+            </Block>
+          );
+        })}
+        {enableExternal && (
+          <Block
+            key={"external"}
+            onClick={() => {
+              selectProvider("external");
+            }}
+          >
+            <Icon src={""} />
+            <BlockTitle>External Cluster</BlockTitle>
+            <CostSection
+              onClick={(e) => {
+                e.stopPropagation();
+                selectProvider("external");
+              }}
+            ></CostSection>
+            <BlockDescription>
+              Connect your own cluster via CLI.
+            </BlockDescription>
+          </Block>
+        )}
+      </BlockList>
+    </>
+  );
+};
+
+export default ProviderSelector;
+
+const CostSection = styled.p`
+  position: absolute;
+  left: 0;
+`;
+
+const BlockList = styled.div`
+  overflow: visible;
+  margin-top: 25px;
+  margin-bottom: 27px;
+  display: grid;
+  grid-column-gap: 25px;
+  grid-row-gap: 25px;
+  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+`;
+
+const Icon = styled.img<{ bw?: boolean }>`
+  height: 42px;
+  margin-top: 30px;
+  margin-bottom: 15px;
+  filter: ${(props) => (props.bw ? "grayscale(1)" : "")};
+`;
+
+const BlockTitle = styled.div`
+  margin-bottom: 12px;
+  width: 80%;
+  text-align: center;
+  font-size: 14px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const Block = styled.div<{ disabled?: boolean }>`
+  align-items: center;
+  user-select: none;
+  border-radius: 5px;
+  display: flex;
+  font-size: 13px;
+  overflow: hidden;
+  font-weight: 500;
+  padding: 3px 0px 5px;
+  flex-direction: column;
+  align-item: center;
+  justify-content: space-between;
+  height: 170px;
+  cursor: ${(props) => (props.disabled ? "" : "pointer")};
+  color: #ffffff;
+  position: relative;
+  background: #26282f;
+  box-shadow: 0 3px 5px 0px #00000022;
+  :hover {
+    background: ${(props) => (props.disabled ? "" : "#ffffff11")};
+  }
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const BlockDescription = styled.div`
+  margin-bottom: 12px;
+  color: #ffffff66;
+  text-align: center;
+  font-weight: default;
+  font-size: 13px;
+  padding: 0px 25px;
+  height: 2.4em;
+  font-size: 12px;
+  display: -webkit-box;
+  overflow: hidden;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+`;

+ 120 - 0
dashboard/src/main/home/onboarding/state/StateHandler.ts

@@ -0,0 +1,120 @@
+import { proxy } from "valtio";
+import type {
+  AWSProvisionerConfig,
+  AWSRegistryConfig,
+  DORegistryConfig,
+  GCPProvisionerConfig,
+  GCPRegistryConfig,
+  SkipProvisionConfig,
+  SkipRegistryConnection,
+  SupportedProviders,
+} from "../types";
+
+export type ConnectedRegistryConfig =
+  | AWSRegistryConfig
+  | GCPRegistryConfig
+  | DORegistryConfig
+  | SkipRegistryConnection;
+
+export type ProvisionerConfig =
+  | AWSProvisionerConfig
+  | GCPProvisionerConfig
+  // | DOProvisionerConfig
+  | SkipProvisionConfig;
+
+export type ProjectData = {
+  id: number;
+  name: string;
+};
+
+export type ConnectedSourceData = {
+  source: "github" | "docker";
+};
+
+export type OnboardingState = {
+  project: ProjectData | null;
+  connected_source: ConnectedSourceData | null;
+  connected_registry: any | null;
+  provision_resources: Partial<ProvisionerConfig> | null;
+  actions: {
+    restoreState: (state: OnboardingState) => void;
+    clearState: () => void;
+    [key: string]: any;
+  };
+};
+
+export type StateKeys = keyof Omit<OnboardingState, "actions">;
+
+export const StateHandler = proxy({
+  project: null,
+  connected_source: null,
+  connected_registry: null,
+  provision_resources: null,
+  actions: {
+    restoreState: (prevState: any) => {
+      StateHandler.project = prevState.project;
+      StateHandler.connected_source = prevState.connected_source;
+      StateHandler.connected_registry = prevState.connected_registry;
+      StateHandler.provision_resources = prevState.provision_resources;
+    },
+    clearState: () => {
+      StateHandler.project = null;
+      StateHandler.connected_source = null;
+      StateHandler.connected_registry = null;
+      StateHandler.provision_resources = null;
+    },
+    saveProjectData: (projectData: any) => {
+      StateHandler.project = projectData;
+    },
+    saveSelectedSource: (source: string) => {
+      StateHandler.connected_source = source;
+    },
+    skipRegistryConnection: () => {
+      StateHandler.connected_registry = {
+        skip: true,
+      };
+    },
+    saveRegistryProvider: (provider: string) => {
+      StateHandler.connected_registry = {
+        skip: false,
+        provider: provider as any,
+      };
+    },
+    saveRegistryCredentials: (credentials: any) => {
+      StateHandler.connected_registry = {
+        ...StateHandler.connected_registry,
+        credentials,
+      };
+    },
+    saveRegistrySettings: (settings: any) => {
+      StateHandler.connected_registry = {
+        ...StateHandler.connected_registry,
+        settings,
+      };
+    },
+
+    skipResourceProvisioning: () => {
+      StateHandler.provision_resources = {
+        skip: true,
+      };
+    },
+    saveResourceProvisioningProvider: (provider: string) => {
+      StateHandler.provision_resources = {
+        skip: provider === "external",
+        provider: provider as any,
+      };
+    },
+    saveResourceProvisioningCredentials: (credentials: any) => {
+      StateHandler.provision_resources = {
+        ...StateHandler.provision_resources,
+        ...credentials,
+      };
+    },
+    saveResourceProvisioningSettings: (settings: any) => {
+      StateHandler.provision_resources = {
+        ...StateHandler.provision_resources,
+        ...settings,
+      };
+    },
+  },
+});

+ 242 - 0
dashboard/src/main/home/onboarding/state/StepHandler.ts

@@ -0,0 +1,242 @@
+import { useEffect } from "react";
+import { useLocation } from "react-router";
+import { useRouting } from "shared/routing";
+import { proxy, useSnapshot } from "valtio";
+import { devtools } from "valtio/utils";
+import { StepKey, Steps } from "../types";
+import { StateKeys } from "./StateHandler";
+
+type Step = {
+  previous?: StepKey;
+  url: string;
+  final?: true;
+  substeps?: {
+    [key in string]: SubStep;
+  };
+  on?: ActionHandler;
+  execute?: {
+    on: {
+      skip?: string;
+      continue?: string;
+    };
+  };
+};
+
+type SubStep = Omit<Step, "previous"> & {
+  parent: StepKey;
+  previous?: string;
+};
+
+export type Action = "skip" | "continue";
+type ActionHandler = {
+  skip?: string;
+  continue: string;
+};
+
+export type FlowType = {
+  initial: StepKey;
+  steps: {
+    [key in Steps]: Step;
+  };
+};
+
+const flow: FlowType = {
+  initial: "new_project",
+  steps: {
+    new_project: {
+      url: "/onboarding/new-project",
+      on: {
+        continue: "connect_source",
+      },
+      execute: {
+        on: {
+          continue: "saveProjectData",
+        },
+      },
+    },
+    connect_source: {
+      previous: "new_project",
+      url: "/onboarding/source",
+      on: {
+        continue: "connect_registry",
+      },
+      execute: {
+        on: {
+          continue: "saveSelectedSource",
+        },
+      },
+    },
+    connect_registry: {
+      previous: "connect_source",
+      url: "/onboarding/registry",
+      on: {
+        skip: "provision_resources",
+        continue: "connect_registry.credentials",
+      },
+      execute: {
+        on: {
+          skip: "skipRegistryConnection",
+          continue: "saveRegistryProvider",
+        },
+      },
+      substeps: {
+        credentials: {
+          url: "/onboarding/registry/credentials",
+          on: {
+            continue: "connect_registry.settings",
+          },
+          parent: "connect_registry",
+          execute: {
+            on: {
+              continue: "saveRegistryCredentials",
+            },
+          },
+        },
+        settings: {
+          previous: "credentials",
+          url: "/onboarding/registry/settings",
+          on: {
+            continue: "connect_registry.test_connection",
+          },
+          parent: "connect_registry",
+          execute: {
+            on: {
+              continue: "saveRegistrySettings",
+            },
+          },
+        },
+        test_connection: {
+          previous: "settings",
+          url: "/onboarding/registry/test_connection",
+          on: {
+            continue: "provision_resources",
+          },
+          parent: "connect_registry",
+        },
+      },
+    },
+    provision_resources: {
+      previous: "connect_registry",
+      url: "/onboarding/provision",
+      on: {
+        skip: "provision_resources.connect_own_cluster",
+        continue: "provision_resources.credentials",
+      },
+      execute: {
+        on: {
+          skip: "skipResourceProvisioning",
+          continue: "saveResourceProvisioningProvider",
+        },
+      },
+      substeps: {
+        connect_own_cluster: {
+          url: "/onboarding/provision/connect_own_cluster",
+          on: {
+            continue: "clean_up",
+          },
+          parent: "provision_resources",
+        },
+        credentials: {
+          url: "/onboarding/provision/credentials",
+          on: { continue: "provision_resources.settings" },
+          parent: "provision_resources",
+          execute: {
+            on: {
+              continue: "saveResourceProvisioningCredentials",
+            },
+          },
+        },
+        settings: {
+          previous: "credentials",
+          url: "/onboarding/provision/settings",
+          on: {
+            continue: "clean_up",
+          },
+          parent: "provision_resources",
+          execute: {
+            on: {
+              continue: "saveResourceProvisioningSettings",
+            },
+          },
+        },
+      },
+    },
+    clean_up: {
+      final: true,
+      url: "/dashboard?tab=provisioner",
+    },
+  },
+};
+
+type StepHandlerType = {
+  flow: FlowType;
+  currentStepName: string;
+  currentStep: Step | SubStep;
+  actions: {
+    nextStep: (action?: Action) => void;
+    clearState: () => void;
+    restoreState: (prevState: StepHandlerType) => void;
+    getStep: (nextStepName: string) => Step | SubStep;
+  };
+};
+
+export const StepHandler: StepHandlerType = proxy({
+  flow,
+  currentStepName: flow.initial,
+  currentStep: flow.steps[flow.initial],
+  actions: {
+    nextStep: (action: Action = "continue") => {
+      const cs = StepHandler.currentStep;
+
+      if (cs.final) {
+        return;
+      }
+
+      const nextStepName = cs.on[action];
+
+      if (!nextStepName) {
+        throw new Error(
+          "No next step name found, fix the action triggering nextStep"
+        );
+      }
+
+      StepHandler.currentStepName = nextStepName;
+      StepHandler.currentStep = StepHandler.actions.getStep(nextStepName);
+      return;
+    },
+    getStep: (nextStepName: string) => {
+      const [stepName, substep] = nextStepName.split(".");
+
+      const step = flow.steps[stepName as Steps];
+
+      let nextStep: Step | SubStep = step;
+
+      if (substep) {
+        nextStep = step.substeps[substep];
+      }
+      return nextStep;
+    },
+    clearState: () => {
+      StepHandler.currentStepName = flow.initial;
+      StepHandler.currentStep = flow.steps[flow.initial];
+    },
+    restoreState: (prevState) => {
+      StepHandler.currentStepName = prevState.currentStepName;
+      StepHandler.currentStep = StepHandler.actions.getStep(
+        prevState.currentStepName
+      );
+    },
+  },
+});
+
+export const useSteps = () => {
+  const snap = useSnapshot(StepHandler);
+  const location = useLocation();
+  const { pushFiltered } = useRouting();
+  useEffect(() => {
+    if (snap.currentStepName === "clean_up") {
+      StepHandler.actions.clearState();
+    }
+    pushFiltered(snap.currentStep.url, ["tab"]);
+  }, [location.pathname, snap.currentStep?.url]);
+};

+ 58 - 0
dashboard/src/main/home/onboarding/state/index.ts

@@ -0,0 +1,58 @@
+import { proxy, subscribe } from "valtio";
+import { devtools, subscribeKey } from "valtio/utils";
+import { StateHandler } from "./StateHandler";
+import { Action, StepHandler } from "./StepHandler";
+
+export const OFState = proxy({
+  StateHandler,
+  StepHandler,
+  subscriptions: [],
+  actions: {
+    initializeState: (projectId: number) => {
+      OFState.actions.restoreState(projectId);
+    },
+    nextStep: (action?: Action, data?: any) => {
+      const functionToExecute = StepHandler?.currentStep?.execute?.on[action];
+      if (functionToExecute) {
+        const actions: any = StateHandler.actions;
+        const executable = actions[functionToExecute];
+        if (typeof executable === "function") {
+          executable(data);
+        }
+      }
+      StepHandler.actions.nextStep(action);
+      OFState.actions.saveState();
+    },
+    clearState: () => {
+      StateHandler.actions.clearState();
+      StepHandler.actions.clearState();
+    },
+    saveState: () => {
+      const state = JSON.stringify(OFState);
+      localStorage.setItem(
+        `onboarding-${OFState.StateHandler.project?.id}`,
+        state
+      );
+    },
+    restoreState: (projectId: number) => {
+      const notParsedPrevState = localStorage.getItem(
+        `onboarding-${projectId}`
+      );
+      if (!notParsedPrevState) {
+        return;
+      }
+      const prevState = JSON.parse(notParsedPrevState);
+
+      if (prevState.StepHandler.finishedOnboarding) {
+        return;
+      }
+
+      if (prevState?.StateHandler) {
+        StateHandler.actions.restoreState(prevState.StateHandler);
+      }
+      if (prevState?.StepHandler) {
+        StepHandler.actions.restoreState(prevState.StepHandler);
+      }
+    },
+  },
+});

+ 88 - 0
dashboard/src/main/home/onboarding/steps/ConnectRegistry/ConnectRegistry.tsx

@@ -0,0 +1,88 @@
+import Helper from "components/form-components/Helper";
+import SaveButton from "components/SaveButton";
+import TitleSection from "components/TitleSection";
+import React, { useState } from "react";
+import { useParams } from "react-router";
+
+import styled from "styled-components";
+import ProviderSelector from "../../components/ProviderSelector";
+import { ConnectedRegistryConfig } from "../../state/StateHandler";
+import { SupportedProviders } from "../../types";
+
+import FormFlowWrapper from "./forms/FormFlow";
+
+const ConnectRegistry: React.FC<{
+  provider: SupportedProviders;
+  project: {
+    id: number;
+    name: string;
+  };
+  onSelectProvider: (provider: SupportedProviders) => void;
+  onSaveCredentials: (credentials: any) => void;
+  onSaveSettings: (settings: any) => void;
+  onSuccess: () => void;
+  onSkip: () => void;
+}> = ({
+  onSelectProvider,
+  onSaveCredentials,
+  onSaveSettings,
+  onSuccess,
+  onSkip,
+  project,
+  provider,
+}) => {
+  const { step } = useParams<any>();
+
+  return (
+    <>
+      <TitleSection>Getting Started</TitleSection>
+      <Subtitle>Step 2 of 3</Subtitle>
+      <Helper>
+        {provider
+          ? "Link to an existing Docker registry. Don't worry if you don't know what this is"
+          : "Link to an existing docker registry or continue"}
+      </Helper>
+      {provider ? (
+        <FormFlowWrapper
+          provider={provider}
+          onSaveCredentials={onSaveCredentials}
+          onSaveSettings={onSaveSettings}
+          onSuccess={onSuccess}
+          project={project}
+          currentStep={step}
+        />
+      ) : (
+        <>
+          <ProviderSelector
+            selectProvider={(provider) => {
+              if (provider !== "external") {
+                onSelectProvider(provider);
+              }
+            }}
+          />
+          <NextStep
+            text="Skip step"
+            disabled={false}
+            onClick={() => onSkip()}
+            status={""}
+            makeFlush={true}
+            clearPosition={true}
+            statusPosition="right"
+            saveText=""
+          />
+        </>
+      )}
+    </>
+  );
+};
+
+export default ConnectRegistry;
+
+const Subtitle = styled(TitleSection)`
+  font-size: 16px;
+  margin-top: 16px;
+`;
+
+const NextStep = styled(SaveButton)`
+  margin-top: 24px;
+`;

+ 23 - 0
dashboard/src/main/home/onboarding/steps/ConnectRegistry/ConnectRegistryWrapper.tsx

@@ -0,0 +1,23 @@
+import React from "react";
+import { useSnapshot } from "valtio";
+import { OFState } from "../../state";
+import ConnectRegistry from "./ConnectRegistry";
+
+const ConnectRegistryWrapper = () => {
+  const snap = useSnapshot(OFState);
+  return (
+    <ConnectRegistry
+      provider={snap.StateHandler.connected_registry?.provider}
+      project={snap.StateHandler.project}
+      onSelectProvider={(provider) =>
+        OFState.actions.nextStep("continue", provider)
+      }
+      onSaveCredentials={(data) => OFState.actions.nextStep("continue", data)}
+      onSaveSettings={(data) => OFState.actions.nextStep("continue", data)}
+      onSuccess={() => OFState.actions.nextStep("continue")}
+      onSkip={() => OFState.actions.nextStep("skip")}
+    />
+  );
+};
+
+export default ConnectRegistryWrapper;

+ 119 - 0
dashboard/src/main/home/onboarding/steps/ConnectRegistry/forms/FormFlow.tsx

@@ -0,0 +1,119 @@
+import { ConnectedRegistryConfig } from "main/home/onboarding/state/StateHandler";
+import {
+  SkipRegistryConnection,
+  SupportedProviders,
+} from "main/home/onboarding/types";
+import React, { useMemo } from "react";
+import styled from "styled-components";
+import {
+  CredentialsForm as AWSCredentialsForm,
+  SettingsForm as AWSSettingsForm,
+  TestRegistryConnection as AWSTestRegistryConnection,
+} from "./_AWSRegistryForm";
+
+import {
+  CredentialsForm as DOCredentialsForm,
+  SettingsForm as DOSettingsForm,
+  TestRegistryConnection as DOTestRegistryConnection,
+} from "./_DORegistryForm";
+
+import {
+  CredentialsForm as GCPCredentialsForm,
+  SettingsForm as GCPSettingsForm,
+  TestRegistryConnection as GCPTestRegistryConnection,
+} from "./_GCPRegistryForm";
+
+const Forms = {
+  aws: {
+    credentials: AWSCredentialsForm,
+    settings: AWSSettingsForm,
+    test_connection: AWSTestRegistryConnection,
+  },
+  gcp: {
+    credentials: GCPCredentialsForm,
+    settings: GCPSettingsForm,
+    test_connection: GCPTestRegistryConnection,
+  },
+  do: {
+    credentials: DOCredentialsForm,
+    settings: DOSettingsForm,
+    test_connection: DOTestRegistryConnection,
+  },
+};
+
+const FormTitle = {
+  aws: "Amazon Elastic Container Registry (ECR)",
+  gcp: "Google Container Registry (GCR)",
+  do: "Digital Ocean Container Registry",
+};
+
+type Props = {
+  provider: SupportedProviders;
+  onSaveCredentials: (credentials: any) => void;
+  onSaveSettings: (settings: any) => void;
+  onSuccess: () => void;
+  project: { id: number; name: string };
+  currentStep: "credentials" | "settings" | "test_connection";
+};
+
+const FormFlowWrapper: React.FC<Props> = ({
+  onSaveCredentials,
+  onSaveSettings,
+  onSuccess,
+  provider,
+  project,
+  currentStep,
+}) => {
+  const nextFormStep = (
+    data?: Partial<Exclude<ConnectedRegistryConfig, SkipRegistryConnection>>
+  ) => {
+    if (currentStep === "credentials") {
+      onSaveCredentials(data.credentials);
+    } else if (currentStep === "settings") {
+      onSaveSettings(data.settings);
+    } else if (currentStep === "test_connection") {
+      onSuccess();
+    }
+  };
+
+  const CurrentForm = useMemo(() => {
+    const providerSteps = Forms[provider];
+    if (!providerSteps) {
+      return null;
+    }
+
+    const currentForm = providerSteps[currentStep];
+    if (!currentForm) {
+      return null;
+    }
+
+    return React.createElement(currentForm as any, {
+      nextFormStep,
+      project,
+    });
+  }, [provider, currentStep]);
+
+  return (
+    <>
+      {FormTitle[provider]}
+      <Breadcrumb>
+        <Text bold={currentStep === "credentials"}>Credentials</Text>
+        {" > "}
+        <Text bold={currentStep === "settings"}>Settings</Text>
+        {" > "}
+        <Text bold={currentStep === "test_connection"}>Test Connection</Text>
+      </Breadcrumb>
+      {CurrentForm}
+    </>
+  );
+};
+
+export default FormFlowWrapper;
+
+const Text = styled.span<{ bold: boolean }>`
+  font-weight: ${(props) => (props.bold ? "600" : "normal")};
+`;
+
+const Breadcrumb = styled.div`
+  margin: 0 10px;
+`;

+ 220 - 0
dashboard/src/main/home/onboarding/steps/ConnectRegistry/forms/_AWSRegistryForm.tsx

@@ -0,0 +1,220 @@
+import InputRow from "components/form-components/InputRow";
+import SelectRow from "components/form-components/SelectRow";
+import SaveButton from "components/SaveButton";
+import { AWSRegistryConfig } from "main/home/onboarding/types";
+import React, { useState } from "react";
+import api from "shared/api";
+import { useSnapshot } from "valtio";
+import { OFState } from "../../../state/index";
+
+const regionOptions = [
+  { value: "us-east-1", label: "US East (N. Virginia) us-east-1" },
+  { value: "us-east-2", label: "US East (Ohio) us-east-2" },
+  { value: "us-west-1", label: "US West (N. California) us-west-1" },
+  { value: "us-west-2", label: "US West (Oregon) us-west-2" },
+  { value: "af-south-1", label: "Africa (Cape Town) af-south-1" },
+  { value: "ap-east-1", label: "Asia Pacific (Hong Kong) ap-east-1" },
+  { value: "ap-south-1", label: "Asia Pacific (Mumbai) ap-south-1" },
+  { value: "ap-northeast-2", label: "Asia Pacific (Seoul) ap-northeast-2" },
+  { value: "ap-southeast-1", label: "Asia Pacific (Singapore) ap-southeast-1" },
+  { value: "ap-southeast-2", label: "Asia Pacific (Sydney) ap-southeast-2" },
+  { value: "ap-northeast-1", label: "Asia Pacific (Tokyo) ap-northeast-1" },
+  { value: "ca-central-1", label: "Canada (Central) ca-central-1" },
+  { value: "eu-central-1", label: "Europe (Frankfurt) eu-central-1" },
+  { value: "eu-west-1", label: "Europe (Ireland) eu-west-1" },
+  { value: "eu-west-2", label: "Europe (London) eu-west-2" },
+  { value: "eu-south-1", label: "Europe (Milan) eu-south-1" },
+  { value: "eu-west-3", label: "Europe (Paris) eu-west-3" },
+  { value: "eu-north-1", label: "Europe (Stockholm) eu-north-1" },
+  { value: "me-south-1", label: "Middle East (Bahrain) me-south-1" },
+  { value: "sa-east-1", label: "South America (São Paulo) sa-east-1" },
+];
+
+export const CredentialsForm: React.FC<{
+  nextFormStep: (data: Partial<AWSRegistryConfig>) => void;
+  project: any;
+}> = ({ nextFormStep, project }) => {
+  const [accessId, setAccessId] = useState("");
+  const [secretKey, setSecretKey] = useState("");
+  const [buttonStatus, setButtonStatus] = useState("");
+  const [awsRegion, setAWSRegion] = useState("us-east-1");
+
+  const validate = () => {
+    if (!accessId) {
+      return {
+        hasError: true,
+        error: "Access ID cannot be empty",
+      };
+    }
+    if (!secretKey) {
+      return {
+        hasError: true,
+        error: "AWS Secret key cannot be empty",
+      };
+    }
+    return {
+      hasError: false,
+      error: "",
+    };
+  };
+
+  const submit = async () => {
+    const validation = validate();
+    if (validation.hasError) {
+      setButtonStatus(validation.error);
+      return;
+    }
+
+    // const res = await api.createAWSIntegration(
+    //   "token",
+    //   {
+    //     aws_region: awsRegion,
+    //     aws_access_key_id: accessId,
+    //     aws_secret_access_key: secretKey,
+    //   },
+    //   {
+    //     id: project.id,
+    //   }
+    // );
+
+    nextFormStep({
+      credentials: {
+        id: "some_id",
+      },
+    });
+  };
+
+  return (
+    <>
+      <InputRow
+        type="text"
+        value={accessId}
+        setValue={(x: string) => {
+          setAccessId(x);
+        }}
+        label="👤 AWS Access ID"
+        placeholder="ex: AKIAIOSFODNN7EXAMPLE"
+        width="100%"
+        isRequired={true}
+      />
+      <InputRow
+        type="password"
+        value={secretKey}
+        setValue={(x: string) => {
+          setSecretKey(x);
+        }}
+        label="🔒 AWS Secret Key"
+        placeholder="○ ○ ○ ○ ○ ○ ○ ○ ○"
+        width="100%"
+        isRequired={true}
+      />
+      <SelectRow
+        options={regionOptions}
+        width="100%"
+        value={awsRegion}
+        dropdownMaxHeight="240px"
+        setActiveValue={(x: string) => {
+          setAWSRegion(x);
+        }}
+        label="📍 AWS Region"
+      />
+      <SaveButton
+        text="Continue"
+        disabled={false}
+        onClick={submit}
+        makeFlush={true}
+        clearPosition={true}
+        status={buttonStatus}
+        statusPosition={"right"}
+      />
+    </>
+  );
+};
+
+export const SettingsForm: React.FC<{
+  nextFormStep: (data: Partial<AWSRegistryConfig>) => void;
+  project: any;
+}> = ({ nextFormStep, project }) => {
+  const snap = useSnapshot(OFState);
+  const [registryName, setRegistryName] = useState("");
+
+  const [buttonStatus, setButtonStatus] = useState("");
+  const validate = () => {
+    if (!registryName) {
+      return {
+        hasError: true,
+        error: "Registry name cannot be empty",
+      };
+    }
+    return {
+      hasError: false,
+      error: "",
+    };
+  };
+
+  const submit = async () => {
+    const validation = validate();
+    if (validation.hasError) {
+      setButtonStatus(validation.error);
+      return;
+    }
+
+    // await api.connectECRRegistry(
+    //   "<token>",
+    //   {
+    //     name: registryName,
+    //     aws_integration_id: snap.StateHandler.connected_registry.credentials.id,
+    //   },
+    //   { id: project.id }
+    // );
+
+    nextFormStep({
+      settings: {
+        registry_name: registryName,
+      },
+    });
+  };
+
+  return (
+    <>
+      <InputRow
+        type="text"
+        value={registryName}
+        setValue={(x) => {
+          setRegistryName(String(x));
+        }}
+        label="🏷️ Registry Name"
+        placeholder="ex: porter-awesome-registry"
+        width="100%"
+      />
+
+      <SaveButton
+        text="Connect Registry"
+        disabled={false}
+        onClick={submit}
+        makeFlush={true}
+        clearPosition={true}
+        status={buttonStatus}
+        statusPosition={"right"}
+      />
+    </>
+  );
+};
+
+export const TestRegistryConnection: React.FC<{ nextFormStep: () => void }> = ({
+  nextFormStep,
+}) => {
+  return (
+    <>
+      <SaveButton
+        text="Continue"
+        disabled={false}
+        onClick={nextFormStep}
+        makeFlush={true}
+        clearPosition={true}
+        status={""}
+        statusPosition={"right"}
+      />
+    </>
+  );
+};

+ 184 - 0
dashboard/src/main/home/onboarding/steps/ConnectRegistry/forms/_DORegistryForm.tsx

@@ -0,0 +1,184 @@
+import Helper from "components/form-components/Helper";
+import InputRow from "components/form-components/InputRow";
+import SaveButton from "components/SaveButton";
+import { OFState } from "main/home/onboarding/state";
+import { DORegistryConfig } from "main/home/onboarding/types";
+import React, { useEffect, useState } from "react";
+import api from "shared/api";
+import styled from "styled-components";
+import { useSnapshot } from "valtio";
+
+/**
+ * This will redirect to DO, and we should pass the redirection URI to be /onboarding/registry?provider=do
+ *
+ * After the oauth flow comes back, the first render will go and check if it exists a integration_id for DO in the
+ * current onboarding project, after getting it, the CredentialsForm will use nextFormStep to save the onboarding state.
+ *
+ * If it happens to be an error, it will be shown with the default error handling through the modal.
+ */
+export const CredentialsForm: React.FC<{
+  nextFormStep: (data: Partial<DORegistryConfig>) => void;
+  project: any;
+}> = ({ nextFormStep, project }) => {
+  useEffect(() => {
+    api.getOAuthIds("<token>", {}, { project_id: project?.id }).then((res) => {
+      let tgtIntegration = res.data.find((integration: any) => {
+        return integration.client === "do";
+      });
+
+      if (tgtIntegration) {
+        nextFormStep({
+          credentials: {
+            id: tgtIntegration.id,
+          },
+        });
+      }
+    });
+  }, []);
+
+  return (
+    <>
+      <ConnectDigitalOceanButton
+        target={"_blank"}
+        href={`/api/projects/${project?.id}/oauth/digitalocean`}
+      >
+        Connect Digital Ocean
+      </ConnectDigitalOceanButton>
+    </>
+  );
+};
+
+export const SettingsForm: React.FC<{
+  nextFormStep: (data: Partial<DORegistryConfig>) => void;
+  project: any;
+}> = ({ nextFormStep, project }) => {
+  const [registryUrl, setRegistryUrl] = useState("basic");
+  const [registryName, setRegistryName] = useState("");
+  const [buttonStatus] = useState("");
+  const snap = useSnapshot(OFState);
+
+  const submit = async () => {
+    await api.connectDORegistry(
+      "<token>",
+      {
+        name: registryName,
+        do_integration_id: snap.StateHandler.connected_registry.credentials.id,
+        url: registryUrl,
+      },
+      { project_id: project.id }
+    );
+    nextFormStep({
+      settings: {
+        registry_url: registryUrl,
+      },
+    });
+  };
+
+  return (
+    <>
+      <InputRow
+        type="text"
+        value={registryName}
+        setValue={(registryName: string) => setRegistryName(registryName)}
+        isRequired={true}
+        label="🏷️ Registry Name"
+        placeholder="ex: paper-straw"
+        width="100%"
+      />
+      <Helper>
+        DOC R URI, in the form{" "}
+        <CodeBlock>registry.digitalocean.com/[REGISTRY_NAME]</CodeBlock>. For
+        example, <CodeBlock>registry.digitalocean.com/porter-test</CodeBlock>.
+      </Helper>
+      <InputRow
+        type="text"
+        value={registryUrl}
+        setValue={(url: string) => setRegistryUrl(url)}
+        label="🔗 GCR URL"
+        placeholder="ex: registry.digitalocean.com/porter-test"
+        width="100%"
+        isRequired={true}
+      />
+      <SaveButton
+        text="Connect Registry"
+        disabled={false}
+        onClick={submit}
+        makeFlush={true}
+        clearPosition={true}
+        status={buttonStatus}
+        statusPosition={"right"}
+      />
+    </>
+  );
+};
+
+export const TestRegistryConnection: React.FC<{
+  nextFormStep: () => void;
+  project: any;
+}> = ({ nextFormStep }) => {
+  return (
+    <>
+      <SaveButton
+        text="Continue"
+        disabled={false}
+        onClick={nextFormStep}
+        makeFlush={true}
+        clearPosition={true}
+        status={""}
+        statusPosition={"right"}
+      />
+    </>
+  );
+};
+
+const CodeBlock = styled.span`
+  display: inline-block;
+  background-color: #1b1d26;
+  color: white;
+  border-radius: 5px;
+  font-family: monospace;
+  padding: 2px 3px;
+  margin-top: -2px;
+  user-select: text;
+`;
+
+const ConnectDigitalOceanButton = styled.a`
+  width: 200px;
+  justify-content: center;
+  border-radius: 5px;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  color: white;
+  font-weight: 500;
+  padding: 10px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;

+ 215 - 0
dashboard/src/main/home/onboarding/steps/ConnectRegistry/forms/_GCPRegistryForm.tsx

@@ -0,0 +1,215 @@
+import Helper from "components/form-components/Helper";
+import InputRow from "components/form-components/InputRow";
+import UploadArea from "components/form-components/UploadArea";
+import SaveButton from "components/SaveButton";
+import { OFState } from "main/home/onboarding/state";
+import { GCPRegistryConfig } from "main/home/onboarding/types";
+import React, { useState } from "react";
+import api from "shared/api";
+import styled from "styled-components";
+import { useSnapshot } from "valtio";
+
+export const CredentialsForm: React.FC<{
+  nextFormStep: (data: Partial<GCPRegistryConfig>) => void;
+  project: any;
+}> = ({ nextFormStep, project }) => {
+  const [projectId, setProjectId] = useState("");
+  const [serviceAccountKey, setServiceAccountKey] = useState("");
+  const [buttonStatus, setButtonStatus] = useState("");
+
+  const validate = () => {
+    if (!projectId) {
+      return { hasError: true, error: "Project ID cannot be empty" };
+    }
+
+    if (!serviceAccountKey) {
+      return { hasError: true, error: "GCP Key Data cannot be empty" };
+    }
+    return {
+      hasError: false,
+      error: "",
+    };
+  };
+
+  const submit = async () => {
+    const validation = validate();
+
+    if (validation.hasError) {
+      setButtonStatus(validation.error);
+      return;
+    }
+    setButtonStatus("loading");
+    // const gcpIntegration = await api
+    //   .createGCPIntegration(
+    //     "<token>",
+    //     {
+    //       gcp_region: "",
+    //       gcp_key_data: serviceAccountKey,
+    //       gcp_project_id: projectId,
+    //     },
+    //     { project_id: project.id }
+    //   )
+    //   .then((res) => res.data);
+
+    nextFormStep({
+      credentials: {
+        id: "some_Id",
+      },
+    });
+  };
+  return (
+    <>
+      <InputRow
+        type="text"
+        value={projectId}
+        setValue={(x: string) => {
+          setProjectId(x);
+        }}
+        label="🏷️ GCP Project ID"
+        placeholder="ex: blindfold-ceiling-24601"
+        width="100%"
+        isRequired={true}
+      />
+
+      <Helper>Service account credentials for GCP permissions.</Helper>
+      <UploadArea
+        setValue={(x: any) => setServiceAccountKey(x)}
+        label="🔒 GCP Key Data (JSON)"
+        placeholder="Choose a file or drag it here."
+        width="100%"
+        height="100%"
+        isRequired={true}
+      />
+      <SaveButton
+        text="Continue"
+        disabled={false}
+        onClick={submit}
+        makeFlush={true}
+        clearPosition={true}
+        status={buttonStatus}
+        statusPosition={"right"}
+      />
+    </>
+  );
+};
+
+export const SettingsForm: React.FC<{
+  nextFormStep: (data: Partial<GCPRegistryConfig>) => void;
+  project: any;
+}> = ({ nextFormStep, project }) => {
+  const [registryName, setRegistryName] = useState("");
+  const [registryUrl, setRegistryUrl] = useState("");
+  const [buttonStatus, setButtonStatus] = useState("");
+  const snap = useSnapshot(OFState);
+
+  const validate = () => {
+    if (!registryName) {
+      return {
+        hasError: true,
+        error: "Registry Name cannot be empty",
+      };
+    }
+    if (!registryUrl) {
+      return {
+        hasError: true,
+        error: "Registry Url cannot be empty",
+      };
+    }
+    return { hasError: false, error: "" };
+  };
+
+  const submit = async () => {
+    const validation = validate();
+
+    if (validation.hasError) {
+      setButtonStatus(validation.error);
+      return;
+    }
+
+    setButtonStatus("loading");
+
+    // await api.connectGCRRegistry(
+    //   "<token>",
+    //   {
+    //     name: registryName,
+    //     gcp_integration_id: snap.StateHandler.connected_registry.credentials.id,
+    //     url: registryUrl,
+    //   },
+    //   {
+    //     id: project.id,
+    //   }
+    // );
+    nextFormStep({
+      settings: {
+        gcr_url: registryUrl,
+        registry_name: registryName,
+      },
+    });
+  };
+  return (
+    <>
+      <InputRow
+        type="text"
+        value={registryName}
+        setValue={(name: string) => setRegistryName(name)}
+        isRequired={true}
+        label="🏷️ Registry Name"
+        placeholder="ex: paper-straw"
+        width="100%"
+      />
+      <Helper>
+        GCR URI, in the form{" "}
+        <CodeBlock>[gcr_domain]/[gcp_project_id]</CodeBlock>. For example,{" "}
+        <CodeBlock>gcr.io/skynet-dev-172969</CodeBlock>.
+      </Helper>
+      <InputRow
+        type="text"
+        value={registryUrl}
+        setValue={(url: string) => setRegistryUrl(url)}
+        label="🔗 GCR URL"
+        placeholder="ex: gcr.io/skynet-dev-172969"
+        width="100%"
+        isRequired={true}
+      />
+      <SaveButton
+        text="Continue"
+        disabled={false}
+        onClick={submit}
+        makeFlush={true}
+        clearPosition={true}
+        status={buttonStatus}
+        statusPosition={"right"}
+      />
+    </>
+  );
+};
+
+export const TestRegistryConnection: React.FC<{
+  nextFormStep: () => void;
+  project: any;
+}> = ({ nextFormStep, project }) => {
+  return (
+    <>
+      <SaveButton
+        text="Continue"
+        disabled={false}
+        onClick={nextFormStep}
+        makeFlush={true}
+        clearPosition={true}
+        status={""}
+        statusPosition={"right"}
+      />
+    </>
+  );
+};
+
+const CodeBlock = styled.span`
+  display: inline-block;
+  background-color: #1b1d26;
+  color: white;
+  border-radius: 5px;
+  font-family: monospace;
+  padding: 2px 3px;
+  margin-top: -2px;
+  user-select: text;
+`;

+ 200 - 0
dashboard/src/main/home/onboarding/steps/ConnectSource.tsx

@@ -0,0 +1,200 @@
+import Helper from "components/form-components/Helper";
+import SaveButton from "components/SaveButton";
+import TitleSection from "components/TitleSection";
+import React, { useEffect, useState } from "react";
+import { Link } from "react-router-dom";
+import api from "shared/api";
+import { useRouting } from "shared/routing";
+import styled from "styled-components";
+import { OFState } from "../state";
+
+interface GithubAppAccessData {
+  username?: string;
+  accounts?: string[];
+}
+
+/**
+ * First step of the flow showing simple Connect to github button, this should
+ * redirect to the github flow
+ *
+ * That way we can be sure that the we have full credentials to launch apps from the user repos.
+ *
+ * The other option would be skip integration, that will skip the whole github connection flow.
+ */
+const ConnectSource: React.FC<{
+  onSuccess: (data: any) => void;
+}> = ({ onSuccess }) => {
+  const [accountData, setAccountData] = useState<GithubAppAccessData>(null);
+  const [isLoading, setIsLoading] = useState(true);
+
+  const getAccounts = async () => {
+    setIsLoading(true);
+    try {
+      const res = await api.getGithubAccounts("<token>", {}, {});
+      if (res.status !== 200) {
+        throw new Error("Not authorized");
+      }
+      return res.data;
+    } catch (error) {
+      console.log(error);
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    let isSubscribed = true;
+    getAccounts().then((accountsData) => {
+      if (isSubscribed) {
+        if (!accountsData) {
+          setAccountData(null);
+        } else {
+          setAccountData(accountsData);
+        }
+      }
+    });
+    return () => {
+      isSubscribed = false;
+    };
+  }, []);
+
+  const nextStep = (selectedSource: "docker" | "github") => {
+    onSuccess(selectedSource);
+  };
+
+  return (
+    <>
+      <TitleSection>Getting Started</TitleSection>
+      <Subtitle>Step 1 of 3</Subtitle>
+      <Helper>
+        To deploy applications from your repo, you need to connect a Github
+        account
+      </Helper>
+      {!isLoading && (!accountData || !accountData?.accounts?.length) && (
+        <>
+          <ConnectToGithubButton href="/api/integrations/github-app/oauth">
+            Connect to github
+          </ConnectToGithubButton>
+          <Helper>
+            No thanks, I want to deploy from a{" "}
+            <A onClick={() => nextStep("docker")}>Docker registry</A>
+          </Helper>
+        </>
+      )}
+      {!isLoading && accountData?.accounts.length && (
+        <>
+          <List>
+            {accountData?.accounts.map((name, i) => {
+              return (
+                <Row key={i} isLastItem={i === accountData.accounts.length - 1}>
+                  <i className="material-icons">bookmark</i>
+                  {name}
+                </Row>
+              );
+            })}
+          </List>
+          <br />
+          Don't see the right repos?{" "}
+          <A href={"/api/integrations/github-app/install"}>
+            Install Porter in more repositories
+          </A>
+          <NextStep
+            text="Continue"
+            disabled={false}
+            onClick={() => nextStep("github")}
+            status={""}
+            makeFlush={true}
+            clearPosition={true}
+            statusPosition="right"
+            saveText=""
+            successText="Project created successfully!"
+          />
+        </>
+      )}
+    </>
+  );
+};
+
+export default ConnectSource;
+
+const NextStep = styled(SaveButton)`
+  margin-top: 24px;
+`;
+
+const A = styled.a`
+  color: #8590ff;
+  text-decoration: underline;
+  margin-left: 5px;
+  cursor: pointer;
+`;
+
+const List = styled.div`
+  width: 100%;
+  background: #ffffff11;
+  border-radius: 5px;
+  margin-top: 20px;
+  border: 1px solid #ffffff44;
+  max-height: 200px;
+  overflow-y: auto;
+`;
+
+const Row = styled.div<{ isLastItem?: boolean }>`
+  width: 100%;
+  height: 35px;
+  color: #ffffff55;
+  display: flex;
+  align-items: center;
+  border-bottom: ${(props) => (props.isLastItem ? "" : "1px solid #ffffff44")};
+  > i {
+    font-size: 17px;
+    margin-left: 10px;
+    margin-right: 12px;
+    color: #ffffff44;
+  }
+`;
+
+const Subtitle = styled(TitleSection)`
+  font-size: 16px;
+  margin-top: 16px;
+`;
+
+const ConnectToGithubButton = styled.a`
+  width: 150px;
+  justify-content: center;
+  border-radius: 5px;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  color: white;
+  font-weight: 500;
+  padding: 10px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;

+ 273 - 0
dashboard/src/main/home/onboarding/steps/NewProject.tsx

@@ -0,0 +1,273 @@
+import React, { useContext, useMemo, useState } from "react";
+import styled from "styled-components";
+
+import gradient from "assets/gradient.png";
+import { isAlphanumeric } from "shared/common";
+
+import InputRow from "components/form-components/InputRow";
+import Helper from "components/form-components/Helper";
+import TitleSection from "components/TitleSection";
+import { useRouting } from "shared/routing";
+import { Context } from "shared/Context";
+import api from "shared/api";
+import SaveButton from "components/SaveButton";
+
+import backArrow from "assets/back_arrow.png";
+
+type ValidationError = {
+  hasError: boolean;
+  description?: string;
+};
+
+export const NewProjectFC: React.FC<{
+  onSuccess: (projectData: { id: number; name: string }) => void;
+}> = ({ onSuccess }) => {
+  const { user, setProjects, setCurrentProject } = useContext(Context);
+  const { pushFiltered } = useRouting();
+  const [buttonStatus, setButtonStatus] = useState("");
+  const [name, setName] = useState("");
+  const { projects } = useContext(Context);
+
+  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 createProject = async () => {
+    const projectName = name;
+    setButtonStatus("loading");
+    const validation = validateProjectName();
+
+    if (validation.hasError) {
+      setButtonStatus(validation.description);
+      return;
+    }
+
+    try {
+      const project = await api
+        .createProject("<token>", { name: projectName }, {})
+        .then((res) => res.data);
+
+      const projectList = await api
+        .getProjects(
+          "<token>",
+          {},
+          {
+            id: user.userId,
+          }
+        )
+        .then((res) => res.data);
+      setProjects(projectList);
+      setCurrentProject(project);
+
+      onSuccess({
+        id: project.id,
+        name: project.name,
+      });
+      setButtonStatus("success");
+    } catch (error) {
+      setButtonStatus("Couldn't create project, try again.");
+      console.log(error);
+    }
+  };
+
+  return (
+    <StyledNewProject>
+      {!isFirstProject && false && (
+        <BackButton
+          onClick={() => {
+            pushFiltered("/dashboard", []);
+          }}
+        >
+          <BackButtonImg src={backArrow} />
+        </BackButton>
+      )}
+      <FadeWrapper>
+        <TitleSection>Create a Project</TitleSection>
+      </FadeWrapper>
+      <FadeWrapper delay="0.7s">
+      <Helper>
+        Project name
+        <Warning highlight={validateProjectName().hasError}>
+          (lowercase letters, numbers, and "-" 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="570px"
+          disabled={buttonStatus === "loading"}
+        />
+      </InputWrapper>
+      <NewProjectSaveButton
+        text="Create Project"
+        disabled={false}
+        onClick={createProject}
+        status={buttonStatus}
+        makeFlush={true}
+        clearPosition={true}
+        statusPosition="right"
+        saveText="Creating project..."
+        successText="Project created successfully!"
+      />
+      </SlideWrapper>
+    </StyledNewProject>
+  );
+};
+
+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 StyledNewProject = styled.div`
+  display: column;
+  align-item: center;
+`;
+
+const NewProjectSaveButton = styled(SaveButton)`
+  margin-top: 24px;
+`;
+
+const Required = styled.div`
+  margin-left: 8px;
+  color: #fc4976;
+  display: inline-block;
+`;
+
+const Letter = styled.div`
+  height: 100%;
+  width: 100%;
+  position: absolute;
+  background: #00000028;
+  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 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;
+`;

+ 81 - 0
dashboard/src/main/home/onboarding/steps/ProvisionResources/ProvisionResources.tsx

@@ -0,0 +1,81 @@
+import Helper from "components/form-components/Helper";
+import SaveButton from "components/SaveButton";
+import TitleSection from "components/TitleSection";
+import React from "react";
+import { useParams } from "react-router";
+import styled from "styled-components";
+import ProviderSelector from "../../components/ProviderSelector";
+
+import FormFlowWrapper from "./forms/FormFlow";
+import ConnectExternalCluster from "./forms/_ConnectExternalCluster";
+import { SupportedProviders } from "../../types";
+
+type Props = {
+  provider: SupportedProviders | "external";
+  project: {
+    id: number;
+    name: string;
+  };
+  shouldProvisionRegistry: boolean;
+  onSelectProvider: (provider: SupportedProviders | "external") => void;
+  onSaveCredentials: (credentials: any) => void;
+  onSaveSettings: (settings: any) => void;
+  onSuccess: () => void;
+  onSkip: () => void;
+};
+
+const ProvisionResources: React.FC<Props> = ({
+  provider,
+  project,
+  shouldProvisionRegistry,
+  onSelectProvider,
+  onSaveCredentials,
+  onSaveSettings,
+  onSuccess,
+}) => {
+  const { step } = useParams<{ step: any }>();
+
+  return (
+    <>
+      <TitleSection>Getting Started</TitleSection>
+      <Subtitle>Step 3 of 3</Subtitle>
+      <Helper>
+        Porter automatically creates a cluster and registry in your cloud to run
+        applications.
+      </Helper>
+      {provider ? (
+        provider !== "external" ? (
+          <FormFlowWrapper
+            provider={provider}
+            currentStep={step}
+            onSaveCredentials={onSaveCredentials}
+            onSaveSettings={onSaveSettings}
+            project={project}
+          />
+        ) : (
+          <ConnectExternalCluster nextStep={onSuccess} project={project} />
+        )
+      ) : (
+        <>
+          <ProviderSelector
+            selectProvider={(provider) => {
+              onSelectProvider(provider);
+            }}
+            enableExternal={!shouldProvisionRegistry}
+          />
+        </>
+      )}
+    </>
+  );
+};
+
+export default ProvisionResources;
+
+const Subtitle = styled(TitleSection)`
+  font-size: 16px;
+  margin-top: 16px;
+`;
+
+const NextStep = styled(SaveButton)`
+  margin-top: 24px;
+`;

+ 24 - 0
dashboard/src/main/home/onboarding/steps/ProvisionResources/ProvisionResourcesWrapper.tsx

@@ -0,0 +1,24 @@
+import React from "react";
+import { useSnapshot } from "valtio";
+import { OFState } from "../../state";
+import ProvisionResources from "./ProvisionResources";
+
+const ProvisionResourcesWrapper = () => {
+  const snap = useSnapshot(OFState);
+  return (
+    <ProvisionResources
+      shouldProvisionRegistry={snap.StateHandler.connected_registry?.skip}
+      provider={snap.StateHandler.provision_resources?.provider}
+      project={snap.StateHandler.project}
+      onSelectProvider={(provider) =>
+        OFState.actions.nextStep("continue", provider)
+      }
+      onSaveCredentials={(data) => OFState.actions.nextStep("continue", data)}
+      onSaveSettings={(data) => OFState.actions.nextStep("continue", data)}
+      onSuccess={() => OFState.actions.nextStep("continue")}
+      onSkip={() => OFState.actions.nextStep("skip")}
+    />
+  );
+};
+
+export default ProvisionResourcesWrapper;

+ 116 - 0
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/FormFlow.tsx

@@ -0,0 +1,116 @@
+import { ProvisionerConfig } from "main/home/onboarding/state/StateHandler";
+import {
+  SkipProvisionConfig,
+  SupportedProviders,
+} from "main/home/onboarding/types";
+import React, { useContext, useMemo } from "react";
+import { Context } from "shared/Context";
+import styled from "styled-components";
+import {
+  CredentialsForm as AWSCredentialsForm,
+  SettingsForm as AWSSettingsForm,
+  Status as AWSProvisionerStatus,
+} from "./_AWSProvsionerForm";
+
+import {
+  CredentialsForm as DOCredentialsForm,
+  SettingsForm as DOSettingsForm,
+  Status as DOProvisionerStatus,
+} from "./_DOProvisionerForm";
+
+import {
+  CredentialsForm as GCPCredentialsForm,
+  SettingsForm as GCPSettingsForm,
+  Status as GCPProvisionerStatus,
+} from "./_GCPProvisionerForm";
+
+const Forms = {
+  aws: {
+    credentials: AWSCredentialsForm,
+    settings: AWSSettingsForm,
+    // status: AWSProvisionerStatus,
+  },
+  gcp: {
+    credentials: GCPCredentialsForm,
+    settings: GCPSettingsForm,
+    // status: GCPProvisionerStatus,
+  },
+  do: {
+    credentials: DOCredentialsForm,
+    settings: DOSettingsForm,
+    // status: DOProvisionerStatus,
+  },
+};
+
+const FormTitle = {
+  aws: "Amazon Web Services (AWS)",
+  gcp: "Google Cloud Platform  (GCP)",
+  do: "Digital Ocean",
+};
+
+type Props = {
+  onSaveCredentials: (credentials: any) => void;
+  onSaveSettings: (settings: any) => void;
+  provider: SupportedProviders | "external";
+  currentStep: "credentials" | "settings";
+  project: { id: number; name: string };
+};
+
+const FormFlowWrapper: React.FC<Props> = ({
+  onSaveCredentials,
+  onSaveSettings,
+  provider,
+  currentStep,
+  project,
+}) => {
+  const nextFormStep = (
+    data?: Partial<Exclude<ProvisionerConfig, SkipProvisionConfig>>
+  ) => {
+    if (currentStep === "credentials") {
+      onSaveCredentials(data);
+    } else if (currentStep === "settings") {
+      onSaveSettings(data);
+    }
+  };
+
+  const CurrentForm = useMemo(() => {
+    if (provider !== "external") {
+      const providerSteps = Forms[provider];
+      if (!providerSteps) {
+        return null;
+      }
+
+      const currentForm = providerSteps[currentStep];
+      if (!currentForm) {
+        return null;
+      }
+
+      return React.createElement(currentForm as any, {
+        nextFormStep,
+        project: project,
+      });
+    }
+  }, [currentStep, provider]);
+
+  return (
+    <>
+      {provider !== "external" && FormTitle[provider]}
+      <Breadcrumb>
+        <Text bold={currentStep === "credentials"}>Credentials</Text>
+        {" > "}
+        <Text bold={currentStep === "settings"}>Settings</Text>
+      </Breadcrumb>
+      {CurrentForm}
+    </>
+  );
+};
+
+export default FormFlowWrapper;
+
+const Text = styled.span<{ bold: boolean }>`
+  font-weight: ${(props) => (props.bold ? "600" : "normal")};
+`;
+
+const Breadcrumb = styled.div`
+  margin: 0 10px;
+`;

+ 282 - 0
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_AWSProvsionerForm.tsx

@@ -0,0 +1,282 @@
+import InputRow from "components/form-components/InputRow";
+import SelectRow from "components/form-components/SelectRow";
+import SaveButton from "components/SaveButton";
+import { OFState } from "main/home/onboarding/state";
+import {
+  AWSProvisionerConfig,
+  AWSRegistryConfig,
+} from "main/home/onboarding/types";
+import React, { useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useSnapshot } from "valtio";
+
+const regionOptions = [
+  { value: "us-east-1", label: "US East (N. Virginia) us-east-1" },
+  { value: "us-east-2", label: "US East (Ohio) us-east-2" },
+  { value: "us-west-1", label: "US West (N. California) us-west-1" },
+  { value: "us-west-2", label: "US West (Oregon) us-west-2" },
+  { value: "af-south-1", label: "Africa (Cape Town) af-south-1" },
+  { value: "ap-east-1", label: "Asia Pacific (Hong Kong) ap-east-1" },
+  { value: "ap-south-1", label: "Asia Pacific (Mumbai) ap-south-1" },
+  { value: "ap-northeast-2", label: "Asia Pacific (Seoul) ap-northeast-2" },
+  { value: "ap-southeast-1", label: "Asia Pacific (Singapore) ap-southeast-1" },
+  { value: "ap-southeast-2", label: "Asia Pacific (Sydney) ap-southeast-2" },
+  { value: "ap-northeast-1", label: "Asia Pacific (Tokyo) ap-northeast-1" },
+  { value: "ca-central-1", label: "Canada (Central) ca-central-1" },
+  { value: "eu-central-1", label: "Europe (Frankfurt) eu-central-1" },
+  { value: "eu-west-1", label: "Europe (Ireland) eu-west-1" },
+  { value: "eu-west-2", label: "Europe (London) eu-west-2" },
+  { value: "eu-south-1", label: "Europe (Milan) eu-south-1" },
+  { value: "eu-west-3", label: "Europe (Paris) eu-west-3" },
+  { value: "eu-north-1", label: "Europe (Stockholm) eu-north-1" },
+  { value: "me-south-1", label: "Middle East (Bahrain) me-south-1" },
+  { value: "sa-east-1", label: "South America (São Paulo) sa-east-1" },
+];
+
+export const CredentialsForm: React.FC<{
+  nextFormStep: (data: Partial<AWSRegistryConfig>) => void;
+  project: any;
+}> = ({ nextFormStep, project }) => {
+  const [accessId, setAccessId] = useState("");
+  const [secretKey, setSecretKey] = useState("");
+  const [awsRegion, setAWSRegion] = useState("us-east-1");
+  const [buttonStatus, setButtonStatus] = useState("");
+
+  const validate = () => {
+    if (!accessId) {
+      return {
+        hasError: true,
+        error: "Access ID cannot be empty",
+      };
+    }
+    if (!secretKey) {
+      return {
+        hasError: true,
+        error: "AWS Secret key cannot be empty",
+      };
+    }
+    return {
+      hasError: false,
+      error: "",
+    };
+  };
+
+  const submit = async () => {
+    const validation = validate();
+    if (validation.hasError) {
+      setButtonStatus(validation.error);
+      return;
+    }
+
+    // const res = await api.createAWSIntegration(
+    //   "token",
+    //   {
+    //     aws_region: awsRegion,
+    //     aws_access_key_id: accessId,
+    //     aws_secret_access_key: secretKey,
+    //   },
+    //   {
+    //     id: project.id,
+    //   }
+    // );
+
+    nextFormStep({
+      credentials: {
+        id: "res.data.id",
+      },
+    });
+  };
+
+  return (
+    <>
+      <InputRow
+        type="text"
+        value={accessId}
+        setValue={(x: string) => {
+          setAccessId(x);
+        }}
+        label="👤 AWS Access ID"
+        placeholder="ex: AKIAIOSFODNN7EXAMPLE"
+        width="100%"
+        isRequired={true}
+      />
+      <InputRow
+        type="password"
+        value={secretKey}
+        setValue={(x: string) => {
+          setSecretKey(x);
+        }}
+        label="🔒 AWS Secret Key"
+        placeholder="○ ○ ○ ○ ○ ○ ○ ○ ○"
+        width="100%"
+        isRequired={true}
+      />
+      <SelectRow
+        options={regionOptions}
+        width="100%"
+        value={awsRegion}
+        dropdownMaxHeight="240px"
+        setActiveValue={(x: string) => {
+          setAWSRegion(x);
+        }}
+        label="📍 AWS Region"
+      />
+      <SaveButton
+        text="Continue"
+        disabled={false}
+        onClick={submit}
+        makeFlush={true}
+        clearPosition={true}
+        status={buttonStatus}
+        statusPosition={"right"}
+      />
+    </>
+  );
+};
+
+const machineTypeOptions = [
+  { value: "t2.medium", label: "t2.medium" },
+  { value: "t2.xlarge", label: "t2.xlarge" },
+  { value: "t2.2xlarge", label: "t2.2xlarge" },
+  { value: "t3.medium", label: "t3.medium" },
+  { value: "t3.xlarge", label: "t3.xlarge" },
+  { value: "t3.2xlarge", label: "t3.2xlarge" },
+];
+
+export const SettingsForm: React.FC<{
+  nextFormStep: (data: Partial<AWSProvisionerConfig>) => void;
+  project: any;
+}> = ({ nextFormStep, project }) => {
+  const snap = useSnapshot(OFState);
+  const [clusterName, setClusterName] = useState("");
+  const [machineType, setMachineType] = useState("t2.medium");
+  const [buttonStatus, setButtonStatus] = useState("");
+
+  const validate = () => {
+    if (!clusterName) {
+      return {
+        hasError: true,
+        error: "Registry name cannot be empty",
+      };
+    }
+    return {
+      hasError: false,
+      error: "",
+    };
+  };
+
+  const catchError = (error: any) => {
+    console.error(error);
+  };
+
+  const provisionECR = async (awsIntegrationId: string) => {
+    console.log("Started provision ECR");
+
+    try {
+      await api.provisionECR(
+        "<token>",
+        {
+          aws_integration_id: awsIntegrationId,
+          ecr_name: `${project.name}-registry`,
+        },
+        { id: project.id }
+      );
+    } catch (error) {
+      catchError(error);
+    }
+  };
+
+  const provisionEKS = async (awsIntegrationId: string) => {
+    try {
+      await api.provisionEKS(
+        "<token>",
+        {
+          aws_integration_id: awsIntegrationId,
+          eks_name: clusterName,
+          machine_type: machineType,
+        },
+        { id: project.id }
+      );
+    } catch (error) {
+      catchError(error);
+    }
+  };
+
+  const submit = async () => {
+    const validation = validate();
+    if (validation.hasError) {
+      setButtonStatus(validation.error);
+      return;
+    }
+    const integrationId = `${snap.StateHandler.provision_resources.credentials.id}`;
+
+    if (snap.StateHandler.connected_registry.skip) {
+      // await provisionECR(integrationId);
+      console.log("PROVISIONING REGISTRY");
+    }
+    // await provisionEKS(integrationId);
+
+    nextFormStep({
+      settings: {
+        cluster_name: clusterName,
+        aws_machine_type: machineType,
+      },
+    });
+  };
+
+  return (
+    <>
+      <InputRow
+        type="text"
+        value={clusterName}
+        setValue={(x) => {
+          setClusterName(String(x));
+        }}
+        label="🏷️ Registry Name"
+        placeholder="ex: porter-awesome-registry"
+        width="100%"
+      />
+      <SelectRow
+        options={machineTypeOptions}
+        width="100%"
+        value={machineType}
+        dropdownMaxHeight="240px"
+        setActiveValue={(x: string) => {
+          setMachineType(x);
+        }}
+        label="⚙️ AWS Machine Type"
+      />
+      <SaveButton
+        text="Provision resources"
+        disabled={false}
+        onClick={submit}
+        makeFlush={true}
+        clearPosition={true}
+        status={buttonStatus}
+        statusPosition={"right"}
+      />
+    </>
+  );
+};
+
+/**
+ * @todo Need to implement provisioner status here
+ */
+export const Status: React.FC<{ nextFormStep: () => void }> = ({
+  nextFormStep,
+}) => {
+  return (
+    <>
+      <SaveButton
+        text="Continue"
+        disabled={false}
+        onClick={nextFormStep}
+        makeFlush={true}
+        clearPosition={true}
+        status={""}
+        statusPosition={"right"}
+      />
+    </>
+  );
+};

+ 248 - 0
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_ConnectExternalCluster.tsx

@@ -0,0 +1,248 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+import TabSelector from "components/TabSelector";
+
+type Props = {
+  nextStep: () => void;
+  project: {
+    id: number;
+    name: string;
+  };
+};
+
+const tabOptions = [{ label: "MacOS", value: "mac" }];
+
+/**
+ * @todo Poll the available clusters until there's at least one connected
+ * to the project
+ */
+const ConnectExternalCluster: React.FC<Props> = ({ nextStep, project }) => {
+  const [currentPage, setCurrentPage] = useState(0);
+  const [currentTab, setCurrentTab] = useState("mac");
+
+  const renderPage = () => {
+    switch (currentPage) {
+      case 0:
+        return (
+          <Placeholder>
+            1. To install the Porter CLI, first retrieve the latest binary:
+            <Code>
+              &#123;
+              <br />
+              name=$(curl -s
+              https://api.github.com/repos/porter-dev/porter/releases/latest |
+              grep "browser_download_url.*/porter_.*_Darwin_x86_64\.zip" | cut
+              -d ":" -f 2,3 | tr -d \")
+              <br />
+              name=$(basename $name)
+              <br />
+              curl -L
+              https://github.com/porter-dev/porter/releases/latest/download/$name
+              --output $name
+              <br />
+              unzip -a $name
+              <br />
+              rm $name
+              <br />
+              &#125;
+            </Code>
+            2. Move the file into your bin:
+            <Code>
+              chmod +x ./porter
+              <br />
+              sudo mv ./porter /usr/local/bin/porter
+            </Code>
+            3. Log in to the Porter CLI:
+            <Code>
+              porter config set-host {location.protocol + "//" + location.host}
+              <br />
+              porter auth login
+            </Code>
+            4. Configure the Porter CLI and link your current context:
+            <Code>
+              porter config set-project {project.id}
+              <br />
+              porter connect kubeconfig
+            </Code>
+          </Placeholder>
+        );
+      case 1:
+        return (
+          <Placeholder>
+            <Bold>Passing a kubeconfig explicitly</Bold>
+            You can pass a path to a kubeconfig file explicitly via:
+            <Code>
+              porter connect kubeconfig --kubeconfig path/to/kubeconfig
+            </Code>
+            <Bold>Passing a context list</Bold>
+            You can initialize Porter with a set of contexts by passing a
+            context list to start. The contexts that Porter will be able to
+            access are the same as kubectl config get-contexts. For example, if
+            there are two contexts named minikube and staging, you could connect
+            both of them via:
+            <Code>
+              porter connect kubeconfig --context minikube --context staging
+            </Code>
+          </Placeholder>
+        );
+      default:
+        return;
+    }
+  };
+
+  return (
+    <StyledClusterInstructionsModal>
+      <TabSelector
+        options={tabOptions}
+        currentTab={currentTab}
+        setCurrentTab={(value: string) => setCurrentTab(value)}
+      />
+
+      {renderPage()}
+      <PageSection>
+        <PageCount>{currentPage + 1}/2</PageCount>
+        <i
+          className="material-icons"
+          onClick={() =>
+            currentPage > 0 ? setCurrentPage(currentPage - 1) : null
+          }
+        >
+          arrow_back
+        </i>
+        <i
+          className="material-icons"
+          onClick={() =>
+            currentPage < 1 ? setCurrentPage(currentPage + 1) : null
+          }
+        >
+          arrow_forward
+        </i>
+      </PageSection>
+    </StyledClusterInstructionsModal>
+  );
+};
+
+export default ConnectExternalCluster;
+
+const PageCount = styled.div`
+  margin-right: 9px;
+  user-select: none;
+  letter-spacing: 2px;
+`;
+
+const PageSection = styled.div`
+  position: absolute;
+  bottom: 22px;
+  right: 20px;
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  color: #ffffff;
+  justify-content: flex-end;
+  user-select: none;
+
+  > i {
+    font-size: 18px;
+    margin-left: 2px;
+    cursor: pointer;
+    border-radius: 20px;
+    padding: 5px;
+    :hover {
+      background: #ffffff11;
+    }
+  }
+`;
+
+const Code = styled.div`
+  background: #181b21;
+  padding: 10px 15px;
+  border: 1px solid #ffffff44;
+  border-radius: 5px;
+  margin: 10px 0px 15px;
+  color: #ffffff;
+  font-size: 13px;
+  user-select: text;
+  line-height: 1em;
+  font-family: monospace;
+`;
+
+const A = styled.a`
+  color: #ffffff;
+  text-decoration: underline;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+`;
+
+const Placeholder = styled.div`
+  color: #aaaabb;
+  font-size: 13px;
+  margin-left: 0px;
+  margin-top: 25px;
+  line-height: 1.6em;
+  user-select: none;
+`;
+
+const Bold = styled.div`
+  font-weight: 600;
+  margin-bottom: 7px;
+`;
+
+const Subtitle = styled.div`
+  padding: 17px 0px 25px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  margin-top: 3px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+const ModalTitle = styled.div`
+  margin: 0px 0px 13px;
+  display: flex;
+  flex: 1;
+  font-family: Work Sans, sans-serif;
+  font-size: 18px;
+  color: #ffffff;
+  user-select: none;
+  font-weight: 700;
+  align-items: center;
+  position: relative;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  z-index: 1;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;
+
+const StyledClusterInstructionsModal = styled.div`
+  width: 100%;
+  position: absolute;
+  left: 0;
+  top: 0;
+  height: 100%;
+  padding: 25px 32px;
+  overflow: hidden;
+  border-radius: 6px;
+  background: #202227;
+`;

+ 273 - 0
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_DOProvisionerForm.tsx

@@ -0,0 +1,273 @@
+import Helper from "components/form-components/Helper";
+import InputRow from "components/form-components/InputRow";
+import SelectRow from "components/form-components/SelectRow";
+import SaveButton from "components/SaveButton";
+import { OFState } from "main/home/onboarding/state";
+import { DOProvisionerConfig } from "main/home/onboarding/types";
+import React, { useEffect, useState } from "react";
+import api from "shared/api";
+import styled from "styled-components";
+import { useSnapshot } from "valtio";
+
+const tierOptions = [
+  { value: "basic", label: "Basic" },
+  { value: "professional", label: "Professional" },
+];
+
+const regionOptions = [
+  { value: "ams3", label: "Amsterdam 3" },
+  { value: "blr1", label: "Bangalore 1" },
+  { value: "fra1", label: "Frankfurt 1" },
+  { value: "lon1", label: "London 1" },
+  { value: "nyc1", label: "New York 1" },
+  { value: "nyc3", label: "New York 3" },
+  { value: "sfo2", label: "San Francisco 2" },
+  { value: "sfo3", label: "San Francisco 3" },
+  { value: "sgp1", label: "Singapore 1" },
+  { value: "tor1", label: "Toronto 1" },
+];
+
+/**
+ * This will redirect to DO, and we should pass the redirection URI to be /onboarding/provision?provider=do
+ *
+ * After the oauth flow comes back, the first render will go and check if it exists a integration_id for DO in the
+ * current onboarding project, after getting it, the CredentialsForm will use nextFormStep to save the onboarding state.
+ *
+ * If it happens to be an error, it will be shown with the default error handling through the modal.
+ */
+export const CredentialsForm: React.FC<{
+  nextFormStep: (data: Partial<DOProvisionerConfig>) => void;
+  project: any;
+}> = ({ nextFormStep, project }) => {
+  useEffect(() => {
+    api.getOAuthIds("<token>", {}, { project_id: project?.id }).then((res) => {
+      let tgtIntegration = res.data.find((integration: any) => {
+        return integration.client === "do";
+      });
+
+      if (tgtIntegration) {
+        nextFormStep({
+          credentials: {
+            id: tgtIntegration.id,
+          },
+        });
+      }
+    });
+  }, []);
+  return (
+    <>
+      <ConnectDigitalOceanButton
+        target={"_blank"}
+        href={`/api/projects/${project?.id}/oauth/digitalocean`}
+      >
+        Connect Digital Ocean
+      </ConnectDigitalOceanButton>
+    </>
+  );
+};
+
+export const SettingsForm: React.FC<{
+  nextFormStep: (data: Partial<DOProvisionerConfig>) => void;
+  project: any;
+}> = ({ nextFormStep, project }) => {
+  const snap = useSnapshot(OFState);
+  const [buttonStatus, setButtonStatus] = useState("");
+  const [tier, setTier] = useState("basic");
+  const [region, setRegion] = useState("nyc1");
+  const [clusterName, setClusterName] = useState("");
+
+  const validate = () => {
+    if (!clusterName) {
+      return {
+        hasError: true,
+        error: "Cluster name cannot be empty",
+      };
+    }
+    if (clusterName.length > 25) {
+      return {
+        hasError: true,
+        error: "Cluster name cannot be longer than 25 characters",
+      };
+    }
+    return {
+      hasError: false,
+      error: "",
+    };
+  };
+
+  const provisionDOCR = async (integrationId: number, tier: string) => {
+    console.log("Provisioning DOCR...");
+    await api.createDOCR(
+      "<token>",
+      {
+        do_integration_id: integrationId,
+        docr_name: project.name,
+        docr_subscription_tier: tier,
+      },
+      {
+        project_id: project.id,
+      }
+    );
+  };
+
+  const provisionDOKS = async (
+    integrationId: number,
+    region: string,
+    clusterName: string
+  ) => {
+    console.log("Provisioning DOKS...");
+    await api.createDOKS(
+      "<token>",
+      {
+        do_integration_id: integrationId,
+        doks_name: clusterName,
+        do_region: region,
+      },
+      {
+        project_id: project.id,
+      }
+    );
+  };
+
+  const submit = async () => {
+    const validation = validate();
+
+    if (validation.hasError) {
+      setButtonStatus(validation.error);
+      return;
+    }
+    const integrationId = snap.StateHandler.provision_resources.credentials.id;
+
+    if (snap.StateHandler.connected_registry.skip) {
+      // await provisionDOCR(integrationId, tier);
+      console.log("PROVISIONING REGISTRY");
+    }
+    // await provisionDOKS(integrationId, region, clusterName);
+
+    nextFormStep({
+      settings: {
+        region,
+        tier,
+        cluster_name: clusterName,
+      },
+    });
+  };
+
+  return (
+    <>
+      <SelectRow
+        options={tierOptions}
+        width="100%"
+        value={tier}
+        setActiveValue={(x: string) => {
+          setTier(x);
+        }}
+        label="💰 Subscription Tier"
+      />
+      <SelectRow
+        options={regionOptions}
+        width="100%"
+        dropdownMaxHeight="240px"
+        value={region}
+        setActiveValue={(x: string) => {
+          setRegion(x);
+        }}
+        label="📍 DigitalOcean Region"
+      />
+      <InputRow
+        type="text"
+        value={clusterName}
+        setValue={(x: string) => {
+          setClusterName(x);
+        }}
+        label="Cluster Name"
+        placeholder="ex: porter-cluster"
+        width="100%"
+        isRequired={true}
+      />
+      <SaveButton
+        text="Connect Registry"
+        disabled={false}
+        onClick={submit}
+        makeFlush={true}
+        clearPosition={true}
+        status={buttonStatus}
+        statusPosition={"right"}
+      />
+    </>
+  );
+};
+
+/**
+ * @todo Need to implement provisioner status here
+ */
+export const Status: React.FC<{
+  nextFormStep: () => void;
+  project: any;
+}> = ({ nextFormStep }) => {
+  return (
+    <>
+      <SaveButton
+        text="Continue"
+        disabled={false}
+        onClick={nextFormStep}
+        makeFlush={true}
+        clearPosition={true}
+        status={""}
+        statusPosition={"right"}
+      />
+    </>
+  );
+};
+
+const CodeBlock = styled.span`
+  display: inline-block;
+  background-color: #1b1d26;
+  color: white;
+  border-radius: 5px;
+  font-family: monospace;
+  padding: 2px 3px;
+  margin-top: -2px;
+  user-select: text;
+`;
+
+const ConnectDigitalOceanButton = styled.a`
+  width: 200px;
+  justify-content: center;
+  border-radius: 5px;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  color: white;
+  font-weight: 500;
+  padding: 10px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;

+ 271 - 0
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_GCPProvisionerForm.tsx

@@ -0,0 +1,271 @@
+import Helper from "components/form-components/Helper";
+import InputRow from "components/form-components/InputRow";
+import SelectRow from "components/form-components/SelectRow";
+import UploadArea from "components/form-components/UploadArea";
+import SaveButton from "components/SaveButton";
+import { OFState } from "main/home/onboarding/state";
+import {
+  GCPProvisionerConfig,
+  GCPRegistryConfig,
+} from "main/home/onboarding/types";
+import React, { useState } from "react";
+import api from "shared/api";
+import styled from "styled-components";
+import { useSnapshot } from "valtio";
+
+const regionOptions = [
+  { value: "asia-east1", label: "asia-east1" },
+  { value: "asia-east2", label: "asia-east2" },
+  { value: "asia-northeast1", label: "asia-northeast1" },
+  { value: "asia-northeast2", label: "asia-northeast2" },
+  { value: "asia-northeast3", label: "asia-northeast3" },
+  { value: "asia-south1", label: "asia-south1" },
+  { value: "asia-southeast1", label: "asia-southeast1" },
+  { value: "asia-southeast2", label: "asia-southeast2" },
+  { value: "australia-southeast1", label: "australia-southeast1" },
+  { value: "europe-north1", label: "europe-north1" },
+  { value: "europe-west1", label: "europe-west1" },
+  { value: "europe-west2", label: "europe-west2" },
+  { value: "europe-west3", label: "europe-west3" },
+  { value: "europe-west4", label: "europe-west4" },
+  { value: "europe-west6", label: "europe-west6" },
+  { value: "northamerica-northeast1", label: "northamerica-northeast1" },
+  { value: "southamerica-east1", label: "southamerica-east1" },
+  { value: "us-central1", label: "us-central1" },
+  { value: "us-east1", label: "us-east1" },
+  { value: "us-east4", label: "us-east4" },
+  { value: "us-west1", label: "us-west1" },
+  { value: "us-west2", label: "us-west2" },
+  { value: "us-west3", label: "us-west3" },
+  { value: "us-west4", label: "us-west4" },
+];
+
+export const CredentialsForm: React.FC<{
+  nextFormStep: (data: Partial<GCPRegistryConfig>) => void;
+  project: any;
+}> = ({ nextFormStep, project }) => {
+  const [projectId, setProjectId] = useState("");
+  const [serviceAccountKey, setServiceAccountKey] = useState("");
+  const [region, setRegion] = useState("us-east1");
+  const [buttonStatus, setButtonStatus] = useState("");
+
+  const validate = () => {
+    if (!projectId) {
+      return { hasError: true, error: "Project ID cannot be empty" };
+    }
+
+    if (!serviceAccountKey) {
+      return { hasError: true, error: "GCP Key Data cannot be empty" };
+    }
+    return {
+      hasError: false,
+      error: "",
+    };
+  };
+
+  const submit = async () => {
+    const validation = validate();
+
+    if (validation.hasError) {
+      setButtonStatus(validation.error);
+      return;
+    }
+    setButtonStatus("loading");
+    // const gcpIntegration = await api
+    //   .createGCPIntegration(
+    //     "<token>",
+    //     {
+    //       gcp_region: "",
+    //       gcp_key_data: serviceAccountKey,
+    //       gcp_project_id: projectId,
+    //     },
+    //     { project_id: project.id }
+    //   )
+    //   .then((res) => res.data);
+
+    nextFormStep({
+      credentials: {
+        id: "gcpIntegration.id",
+      },
+    });
+  };
+  return (
+    <>
+      <InputRow
+        type="text"
+        value={projectId}
+        setValue={(x: string) => {
+          setProjectId(x);
+        }}
+        label="🏷️ GCP Project ID"
+        placeholder="ex: blindfold-ceiling-24601"
+        width="100%"
+        isRequired={true}
+      />
+
+      <Helper>Service account credentials for GCP permissions.</Helper>
+      <UploadArea
+        setValue={(x: any) => setServiceAccountKey(x)}
+        label="🔒 GCP Key Data (JSON)"
+        placeholder="Choose a file or drag it here."
+        width="100%"
+        height="100%"
+        isRequired={true}
+      />
+      <SelectRow
+        options={regionOptions}
+        width="100%"
+        value={region}
+        dropdownMaxHeight="240px"
+        setActiveValue={(x: string) => {
+          setRegion(x);
+        }}
+        label="📍 GCP Region"
+      />
+
+      <SaveButton
+        text="Continue"
+        disabled={false}
+        onClick={submit}
+        makeFlush={true}
+        clearPosition={true}
+        status={buttonStatus}
+        statusPosition={"right"}
+      />
+    </>
+  );
+};
+
+export const SettingsForm: React.FC<{
+  nextFormStep: (data: Partial<GCPProvisionerConfig>) => void;
+  project: any;
+}> = ({ nextFormStep, project }) => {
+  const [clusterName, setClusterName] = useState("");
+  const [buttonStatus, setButtonStatus] = useState("");
+  const snap = useSnapshot(OFState);
+
+  const validate = () => {
+    if (!clusterName) {
+      return {
+        hasError: true,
+        error: "Cluster Name cannot be empty",
+      };
+    }
+
+    return { hasError: false, error: "" };
+  };
+
+  const catchError = (error: any) => {
+    console.error(error);
+  };
+
+  const submit = async () => {
+    const validation = validate();
+
+    if (validation.hasError) {
+      setButtonStatus(validation.error);
+      return;
+    }
+
+    setButtonStatus("loading");
+    const integrationId = snap.StateHandler.provision_resources.credentials.id;
+
+    if (snap.StateHandler.connected_registry.skip) {
+      // await provisionGCR(integrationId);
+      console.log("PROVISIONING REGISTRY");
+    }
+    // await provisionGKE(integrationId);
+    nextFormStep({
+      settings: {
+        cluster_name: clusterName,
+      },
+    });
+  };
+
+  const provisionGCR = (id: number) => {
+    console.log("Provisioning GCR");
+
+    return api
+      .createGCR(
+        "<token>",
+        {
+          gcp_integration_id: id,
+        },
+        { project_id: project.id }
+      )
+      .catch(catchError);
+  };
+
+  const provisionGKE = (id: number) => {
+    console.log("Provisioning GKE");
+
+    return api
+      .createGKE(
+        "<token>",
+        {
+          gke_name: clusterName,
+          gcp_integration_id: id,
+        },
+        { project_id: project.id }
+      )
+      .catch(catchError);
+  };
+
+  return (
+    <>
+      <InputRow
+        type="text"
+        value={clusterName}
+        setValue={(x: string) => {
+          setClusterName(x);
+        }}
+        label="Cluster Name"
+        placeholder="ex: porter-cluster"
+        width="100%"
+        isRequired={true}
+      />
+      <SaveButton
+        text="Continue"
+        disabled={false}
+        onClick={submit}
+        makeFlush={true}
+        clearPosition={true}
+        status={buttonStatus}
+        statusPosition={"right"}
+      />
+    </>
+  );
+};
+
+/**
+ * @todo Need to implement provisioner status here
+ */
+export const Status: React.FC<{
+  nextFormStep: () => void;
+  project: any;
+}> = ({ nextFormStep, project }) => {
+  return (
+    <>
+      <SaveButton
+        text="Continue"
+        disabled={false}
+        onClick={nextFormStep}
+        makeFlush={true}
+        clearPosition={true}
+        status={""}
+        statusPosition={"right"}
+      />
+    </>
+  );
+};
+
+const CodeBlock = styled.span`
+  display: inline-block;
+  background-color: #1b1d26;
+  color: white;
+  border-radius: 5px;
+  font-family: monospace;
+  padding: 2px 3px;
+  margin-top: -2px;
+  user-select: text;
+`;

+ 116 - 0
dashboard/src/main/home/onboarding/types.ts

@@ -0,0 +1,116 @@
+export type SupportedProviders = "aws" | "gcp" | "do";
+
+export enum Steps {
+  NEW_PROJECT = "new_project",
+  CONNECT_SOURCE = "connect_source",
+  CONNECT_REGISTRY = "connect_registry",
+  PROVISION_RESOURCES = "provision_resources",
+  CLEAN_UP = "clean_up",
+}
+
+export type StepKey = `${Steps}`;
+
+export type AWSRegistryConfig = {
+  skip: false;
+  provider: "aws";
+  credentials: {
+    id: string;
+  };
+  settings: {
+    registry_name: string;
+  };
+};
+
+export type GCPRegistryConfig = {
+  skip: false;
+  provider: "gcp";
+  credentials: {
+    id: string;
+  };
+  settings: {
+    registry_name: string;
+    gcr_url: string;
+  };
+};
+
+export type DORegistryConfig = {
+  skip: false;
+  provider: "do";
+  credentials: {
+    id: string;
+  };
+  settings: {
+    registry_url: string;
+  };
+};
+
+export type AWSProvisionerConfig = {
+  skip: false;
+  provider: "aws";
+  credentials: {
+    id: number;
+    arn: string;
+    region: string;
+  };
+  settings: {
+    cluster_name: string;
+    aws_machine_type: string;
+  };
+};
+
+export type GCPProvisionerConfig = {
+  skip: false;
+  provider: "gcp";
+  credentials: {
+    id: number;
+    region: string;
+  };
+  settings: {
+    cluster_name: string;
+  };
+};
+
+export type DOProvisionerConfig = {
+  skip: false;
+  provider: "do";
+  credentials: {
+    id: number;
+  };
+  settings: {
+    region: string;
+    cluster_name: string;
+    tier: string;
+  };
+};
+
+export type SkipProvisionConfig = {
+  skip: true;
+};
+
+export type SkipRegistryConnection = SkipProvisionConfig;
+
+interface Onboarding {
+  current_step: string;
+
+  project_id: number;
+  project_name: string;
+
+  connected_source: "docker" | "github";
+
+  skip_registry_connection: boolean;
+
+  registry_connection_credentials_id: number;
+  registry_connection_settings_url: string;
+  registry_connection_settings_name: string;
+
+  skip_resource_provision: boolean;
+
+  resource_provision_credentials_id: number;
+  resource_provision_credentials_arn: string;
+  resource_provision_credentials_region: string;
+
+  resource_provision_settings_cluster_name: string;
+  resource_provision_settings_region: string;
+  resource_provision_settings_tier: string;
+  resource_provision_settings_machine_type: string;
+}

+ 0 - 32
dashboard/src/main/home/provisioner/AWSFormSection.tsx

@@ -165,34 +165,6 @@ const AWSFormSectionFC: React.FC<PropsType> = (props) => {
     props.handleError();
   };
 
-  // Step 1: Create a project
-  // TODO: promisify this function
-  const createProject = async () => {
-    const { projectName } = props;
-    const { user, setProjects, setCurrentProject } = context;
-    try {
-      const project = await api
-        .createProject("<token>", { name: projectName }, {})
-        .then((res) => res.data);
-
-      // Need to set project list for dropdown
-      // TODO: consolidate into ProjectSection (case on exists in list on set)
-      const projectList = await api
-        .getProjects(
-          "<token>",
-          {},
-          {
-            id: user.userId,
-          }
-        )
-        .then((res) => res.data);
-      setProjects(projectList);
-      setCurrentProject(project);
-    } catch (error) {
-      catchError(error);
-    }
-  };
-
   const getAwsIntegrationId = async () => {
     const { currentProject } = context;
     try {
@@ -253,10 +225,6 @@ const AWSFormSectionFC: React.FC<PropsType> = (props) => {
     setButtonStatus("loading");
     const { projectName } = props;
 
-    if (projectName) {
-      await createProject();
-    }
-
     const awsIntegrationId = await getAwsIntegrationId();
 
     const filterNonAWSInfras = (infra: any) =>

+ 1 - 32
dashboard/src/main/home/provisioner/DOFormSection.tsx

@@ -125,31 +125,6 @@ const DOFormSectionFC: React.FC<PropsType> = (props) => {
     return;
   };
 
-  // Step 1: Create a project
-  const createProject = (callback?: any) => {
-    let { projectName } = props;
-    let { user, setProjects, setCurrentProject } = context;
-
-    api
-      .createProject("<token>", { name: projectName }, {})
-      .then(async (res) => {
-        let proj = res.data;
-
-        // Need to set project list for dropdown
-        // TODO: consolidate into ProjectSection (case on exists in list on set)
-        const res_1 = await api.getProjects(
-          "<token>",
-          {},
-          {
-            id: user.userId,
-          }
-        );
-        setProjects(res_1.data);
-        setCurrentProject(proj, () => callback && callback(proj.id));
-      })
-      .catch(catchError);
-  };
-
   const doRedirect = (projectId: number) => {
     let redirectUrl = `/api/projects/${projectId}/oauth/digitalocean?project_id=${projectId}&provision=do`;
     redirectUrl += `&tier=${subscriptionTier}&region=${doRegion}&cluster_name=${clusterName}`;
@@ -163,14 +138,8 @@ const DOFormSectionFC: React.FC<PropsType> = (props) => {
   // TODO: handle generically (with > 2 steps)
   const onCreateDO = () => {
     props?.trackOnSave();
-    let { projectName } = props;
     let { currentProject } = context;
-
-    if (!projectName) {
-      doRedirect(currentProject.id);
-    } else {
-      createProject((projectId: number) => doRedirect(projectId));
-    }
+    doRedirect(currentProject.id);
   };
 
   const getButtonStatus = () => {

+ 6 - 31
dashboard/src/main/home/provisioner/ExistingClusterSection.tsx

@@ -24,39 +24,14 @@ class ExistingClusterSection extends Component<PropsType, StateType> {
     buttonStatus: "",
   };
 
-  onCreateProject = () => {
+  onSkip = () => {
     this.props?.trackOnSave();
-    let { projectName } = this.props;
-    let { user, setProjects, setCurrentProject } = this.context;
 
     this.setState({ buttonStatus: "loading" });
-    api
-      .createProject("<token>", { name: projectName }, {})
-      .then((res) =>
-        api.getProjects(
-          "<token>",
-          {},
-          {
-            id: user.userId,
-          }
-        )
-      )
-      .then((res) => {
-        if (res.data) {
-          setProjects(res.data);
-          if (res.data.length > 0) {
-            let proj = res.data.find((el: ProjectType) => {
-              return el.name === projectName;
-            });
-            setCurrentProject(proj, () =>
-              pushFiltered(this.props, "/dashboard", ["project_id"], {
-                tab: "overview",
-              })
-            );
-          }
-        }
-      })
-      .catch(console.log);
+
+    pushFiltered(this.props, "/dashboard", ["project_id"], {
+      tab: "overview",
+    });
   };
 
   render() {
@@ -72,7 +47,7 @@ class ExistingClusterSection extends Component<PropsType, StateType> {
         <SaveButton
           text="Submit"
           disabled={!isAlphanumeric(projectName)}
-          onClick={this.onCreateProject}
+          onClick={this.onSkip}
           status={buttonStatus}
           makeFlush={true}
           helper="Note: Provisioning can take up to 15 minutes"

+ 2 - 35
dashboard/src/main/home/provisioner/GCPFormSection.tsx

@@ -150,36 +150,7 @@ const GCPFormSectionFC: React.FC<PropsType> = (props) => {
     props.handleError();
   };
 
-  // Step 1: Create a project
-  const createProject = (callback?: any) => {
-    let { projectName } = props;
-    let { user, setProjects, setCurrentProject } = context;
-
-    api
-      .createProject("<token>", { name: projectName }, {})
-      .then((res) => {
-        let proj = res.data;
-
-        // Need to set project list for dropdown
-        // TODO: consolidate into ProjectSection (case on exists in list on set)
-        api
-          .getProjects(
-            "<token>",
-            {},
-            {
-              id: user.userId,
-            }
-          )
-          .then((res) => {
-            setProjects(res.data);
-            setCurrentProject(proj, () => callback && callback());
-          })
-          .catch(catchError);
-      })
-      .catch(catchError);
-  };
-
-  const provisionGCR = (id: number, callback?: any) => {
+  const provisionGCR = (id: number) => {
     console.log("Provisioning GCR");
     let { currentProject } = context;
 
@@ -260,11 +231,7 @@ const GCPFormSectionFC: React.FC<PropsType> = (props) => {
     setButtonStatus("loading");
     let { projectName } = props;
 
-    if (!projectName) {
-      handleCreateFlow();
-    } else {
-      createProject(handleCreateFlow);
-    }
+    handleCreateFlow();
   };
 
   const getButtonStatus = () => {

+ 8 - 2
dashboard/src/main/home/sidebar/ProjectSection.tsx

@@ -78,7 +78,9 @@ class ProjectSection extends Component<PropsType, StateType> {
               selected={false}
               lastItem={true}
               onClick={() =>
-                pushFiltered(this.props, "/new-project", ["project_id"])
+                pushFiltered(this.props, "/onboarding/new-project", [
+                  "project_id",
+                ])
               }
             >
               <ProjectIconAlt>+</ProjectIconAlt>
@@ -116,7 +118,11 @@ class ProjectSection extends Component<PropsType, StateType> {
     }
     return (
       <InitializeButton
-        onClick={() => pushFiltered(this.props, "new-project", ["project_id"])}
+        onClick={() =>
+          pushFiltered(this.props, "new-project", ["project_id"], {
+            new_project: true,
+          })
+        }
       >
         <Plus>+</Plus> Create a Project
       </InitializeButton>

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

@@ -33,6 +33,17 @@ const connectGCRRegistry = baseApi<
   return `/api/projects/${pathParams.id}/registries`;
 });
 
+const connectDORegistry = baseApi<
+  {
+    name: string;
+    do_integration_id: string;
+    url: string;
+  },
+  { project_id: number }
+>("POST", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/registries`;
+});
+
 const createAWSIntegration = baseApi<
   {
     aws_region: string;
@@ -1060,6 +1071,7 @@ export default {
   checkAuth,
   connectECRRegistry,
   connectGCRRegistry,
+  connectDORegistry,
   createAWSIntegration,
   overwriteAWSIntegration,
   createDOCR,

+ 29 - 1
dashboard/src/shared/routing.tsx

@@ -1,3 +1,5 @@
+import { useHistory, useLocation } from "react-router";
+
 export type PorterUrl =
   | "dashboard"
   | "launch"
@@ -7,7 +9,8 @@ export type PorterUrl =
   | "project-settings"
   | "applications"
   | "env-groups"
-  | "jobs";
+  | "jobs"
+  | "onboarding";
 
 export const PorterUrls = [
   "dashboard",
@@ -19,6 +22,7 @@ export const PorterUrls = [
   "applications",
   "env-groups",
   "jobs",
+  "onboarding",
 ];
 
 // TODO: consolidate with pushFiltered
@@ -66,3 +70,27 @@ export const getQueryParam = (props: any, paramName: string) => {
   const searchParams = getQueryParams(props);
   return searchParams?.get(paramName);
 };
+
+export const useRouting = () => {
+  const location = useLocation();
+  const history = useHistory();
+
+  return {
+    pushQueryParams: (params: { [key: string]: unknown }) => {
+      return pushQueryParams({ location, history }, params);
+    },
+    pushFiltered: (
+      pathname: string,
+      keys: string[],
+      params?: { [key: string]: unknown }
+    ) => {
+      return pushFiltered({ location, history }, pathname, keys, params);
+    },
+    getQueryParams: () => {
+      return getQueryParams({ location });
+    },
+    getQueryParam: (paramName: string) => {
+      return getQueryParam({ location }, paramName);
+    },
+  };
+};

+ 153 - 0
internal/integrations/powerdns/powerdns.go

@@ -0,0 +1,153 @@
+package powerdns
+
+import (
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+)
+
+// Client contains an API client for a PowerDNS server
+type Client struct {
+	apiKey    string
+	serverURL string
+	runDomain string
+
+	httpClient *http.Client
+}
+
+// NewClient creates a new bind API client
+func NewClient(serverURL, apiKey, runDomain string) *Client {
+	httpClient := &http.Client{
+		Timeout: time.Minute,
+	}
+
+	return &Client{apiKey, serverURL, runDomain, httpClient}
+}
+
+// RecordData represents the data required to create or delete an A/CNAME record
+// for the nameserver
+type RecordData struct {
+	RRSets []RR `json:"rrsets"`
+}
+
+type RR struct {
+	Name       string   `json:"name"`
+	Type       string   `json:"type"`
+	ChangeType string   `json:"changetype"`
+	TTL        uint     `json:"ttl"`
+	Records    []Record `json:"records"`
+}
+
+type Record struct {
+	Content  string `json:"content"`
+	Disabled bool   `json:"disabled"`
+	Name     string `json:"name"`
+	Type     string `json:"type"`
+	Priority uint   `json:"priority"`
+}
+
+// CreateCNAMERecord creates a new CNAME record for the nameserver
+func (c *Client) CreateCNAMERecord(value, hostname string) error {
+	valueC := canonicalize(value)
+	hostnameC := canonicalize(hostname)
+
+	return c.sendRequest("PATCH", &RecordData{
+		RRSets: []RR{{
+			Name:       hostnameC,
+			Type:       "CNAME",
+			ChangeType: "REPLACE",
+			TTL:        300,
+			Records: []Record{{
+				Content:  valueC,
+				Disabled: false,
+				Name:     hostnameC,
+				Type:     "CNAME",
+				Priority: 0,
+			}},
+		}},
+	})
+}
+
+// CreateARecord creates a new A record for the nameserver
+func (c *Client) CreateARecord(value, hostname string) error {
+	hostnameC := canonicalize(hostname)
+
+	return c.sendRequest("PATCH", &RecordData{
+		RRSets: []RR{{
+			Name:       hostnameC,
+			Type:       "A",
+			ChangeType: "REPLACE",
+			TTL:        300,
+			Records: []Record{{
+				Content:  value,
+				Disabled: false,
+				Name:     hostnameC,
+				Type:     "A",
+				Priority: 0,
+			}},
+		}},
+	})
+}
+
+func canonicalize(value string) string {
+	// if the string ends in a period, return
+	if value[len(value)-1:] == "." {
+		return value
+	}
+
+	return fmt.Sprintf("%s.", value)
+}
+
+func (c *Client) sendRequest(method string, data *RecordData) error {
+	reqURL, err := url.Parse(c.serverURL)
+
+	if err != nil {
+		return nil
+	}
+
+	reqURL.Path = fmt.Sprintf("/api/v1/servers/localhost/zones/%s", c.runDomain)
+
+	strData, err := json.Marshal(data)
+
+	if err != nil {
+		return err
+	}
+
+	req, err := http.NewRequest(
+		method,
+		reqURL.String(),
+		strings.NewReader(string(strData)),
+	)
+
+	if err != nil {
+		return err
+	}
+
+	req.Header.Set("Content-Type", "application/json; charset=utf-8")
+	req.Header.Set("Accept", "application/json; charset=utf-8")
+	req.Header.Set("X-Api-Key", c.apiKey)
+
+	res, err := c.httpClient.Do(req)
+
+	if err != nil {
+		return err
+	}
+
+	defer res.Body.Close()
+
+	if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusBadRequest {
+		resBytes, err := ioutil.ReadAll(res.Body)
+
+		if err != nil {
+			return fmt.Errorf("request failed with status code %d, but could not read body (%s)\n", res.StatusCode, err.Error())
+		}
+
+		return fmt.Errorf("request failed with status code %d: %s\n", res.StatusCode, string(resBytes))
+	}
+
+	return nil
+}

+ 1 - 4
internal/kubernetes/agent.go

@@ -536,15 +536,12 @@ func (a *Agent) GetPodLogs(namespace string, name string, rw *websocket.Websocke
 
 	// see if container is ready and able to open a stream. If not, wait for container
 	// to be ready.
-	err, isExited := a.waitForPod(pod)
+	err, _ = a.waitForPod(pod)
 
 	if err != nil && goerrors.Is(err, IsNotFoundError) {
 		return IsNotFoundError
 	} else if err != nil {
 		return fmt.Errorf("Cannot get logs from pod %s: %s", name, err.Error())
-	} else if isExited {
-		// if exited, we return nil and simply close the stream
-		return nil
 	}
 
 	container := pod.Spec.Containers[0].Name

+ 6 - 140
internal/kubernetes/domain/domain.go

@@ -6,14 +6,13 @@ import (
 	"net"
 	"strings"
 
+	"github.com/porter-dev/porter/internal/integrations/powerdns"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 	v1 "k8s.io/api/core/v1"
-	"k8s.io/api/extensions/v1beta1"
 	"k8s.io/client-go/kubernetes"
 
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"k8s.io/apimachinery/pkg/util/intstr"
 )
 
 // GetNGINXIngressServiceIP retrieves the external address of the nginx-ingress service
@@ -80,147 +79,14 @@ func (c *CreateDNSRecordConfig) NewDNSRecordForEndpoint() *models.DNSRecord {
 	}
 }
 
-func (e *DNSRecord) CreateDomain(clientset kubernetes.Interface) error {
-	// determine if IP address or domain
-	err := e.createIngress(clientset)
-
-	if err != nil {
-		return err
-	}
-
-	return e.createServiceWithEndpoint(clientset)
-}
-
-func (e *DNSRecord) createIngress(clientset kubernetes.Interface) error {
-	_, err := clientset.ExtensionsV1beta1().Ingresses("default").Create(
-		context.TODO(),
-		&v1beta1.Ingress{
-			ObjectMeta: metav1.ObjectMeta{
-				Annotations: map[string]string{
-					"kubernetes.io/ingress.class":                  "nginx",
-					"nginx.ingress.kubernetes.io/ssl-redirect":     "true",
-					"nginx.ingress.kubernetes.io/backend-protocol": "HTTPS",
-					"nginx.ingress.kubernetes.io/upstream-vhost":   e.Hostname,
-				},
-				Name:      e.SubdomainPrefix,
-				Namespace: "default",
-			},
-			Spec: v1beta1.IngressSpec{
-				TLS: []v1beta1.IngressTLS{
-					{
-						Hosts:      []string{fmt.Sprintf("%s.%s", e.SubdomainPrefix, e.RootDomain)},
-						SecretName: "wildcard-cert-tls",
-					},
-				},
-				Rules: []v1beta1.IngressRule{
-					{
-						Host: fmt.Sprintf("%s.%s", e.SubdomainPrefix, e.RootDomain),
-						IngressRuleValue: v1beta1.IngressRuleValue{
-							HTTP: &v1beta1.HTTPIngressRuleValue{
-								Paths: []v1beta1.HTTPIngressPath{
-									{
-										Backend: v1beta1.IngressBackend{
-											ServiceName: e.SubdomainPrefix,
-											ServicePort: intstr.IntOrString{
-												Type:   intstr.Int,
-												IntVal: 443,
-											},
-										},
-									},
-								},
-							},
-						},
-					},
-				},
-			},
-		},
-		metav1.CreateOptions{},
-	)
-
-	return err
-}
-
-func (e *DNSRecord) createServiceWithEndpoint(clientset kubernetes.Interface) error {
-	// determine if endpoint needs to be created or external name is ok
+// CreateDomain creates a new record for the vanity domain
+func (e *DNSRecord) CreateDomain(powerDNSClient *powerdns.Client) error {
 	isIPv4 := net.ParseIP(e.Endpoint) != nil
-
-	svcSpec := v1.ServiceSpec{
-		Ports: []v1.ServicePort{
-			{
-				Port: 80,
-				TargetPort: intstr.IntOrString{
-					Type:   intstr.Int,
-					IntVal: 80,
-				},
-				Name: "http",
-			},
-			{
-				Port: 443,
-				TargetPort: intstr.IntOrString{
-					Type:   intstr.Int,
-					IntVal: 443,
-				},
-				Name: "https",
-			},
-		},
-	}
-
-	// case service spec on ipv4
-	if isIPv4 {
-		svcSpec.ClusterIP = "None"
-	} else {
-		svcSpec.Type = "ExternalName"
-		svcSpec.ExternalName = e.Endpoint
-	}
-
-	// create service
-	_, err := clientset.CoreV1().Services("default").Create(
-		context.TODO(),
-		&v1.Service{
-			ObjectMeta: metav1.ObjectMeta{
-				Name:      e.SubdomainPrefix,
-				Namespace: "default",
-			},
-			Spec: svcSpec,
-		},
-		metav1.CreateOptions{},
-	)
-
-	if err != nil {
-		return err
-	}
+	domain := fmt.Sprintf("%s.%s", e.SubdomainPrefix, e.RootDomain)
 
 	if isIPv4 {
-		_, err = clientset.CoreV1().Endpoints("default").Create(
-			context.TODO(),
-			&v1.Endpoints{
-				ObjectMeta: metav1.ObjectMeta{
-					Name:      e.SubdomainPrefix,
-					Namespace: "default",
-				},
-				Subsets: []v1.EndpointSubset{
-					{
-						Addresses: []v1.EndpointAddress{
-							{
-								IP: e.Endpoint,
-							},
-						},
-						Ports: []v1.EndpointPort{
-							{
-								Name: "http",
-								Port: 80,
-							},
-							{
-								Name: "https",
-								Port: 443,
-							},
-						},
-					},
-				},
-			},
-			metav1.CreateOptions{},
-		)
+		return powerDNSClient.CreateARecord(e.Endpoint, domain)
 	}
 
-	return err
+	return powerDNSClient.CreateCNAMERecord(e.Endpoint, domain)
 }

+ 7 - 2
scripts/build/win.sh

@@ -2,7 +2,12 @@
 #
 # Accepts the version as an argument
 
-go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd.Version=$1'" -a -tags cli -o ./porter.exe ./cli
+go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd.Version=$1'" -a -tags cli -o ./porter.exe ./cli &
+go build -ldflags="-w -s -X 'main.Version=$1'" -a -o ./docker-credential-porter.exe ./cmd/docker-credential-porter/ &
+go build -ldflags="-w -s -X 'main.Version=$1'" -a -o ./portersvr.exe ./cmd/app/ &
+wait
 
 mkdir -p /release/windows
-zip --junk-paths /release/windows/porter_$1_Windows_x86_64.zip ./porter.exe
+zip --junk-paths /release/windows/porter_$1_Windows_x86_64.zip ./porter.exe
+zip --junk-paths /release/windows/portersvr_$1_Windows_x86_64.zip ./portersvr.exe
+zip --junk-paths /release/windows/docker-credential-porter_$1_Windows_x86_64.zip ./docker-credential-porter.exe