Просмотр исходного кода

Merge pull request #1327 from porter-dev/nico/new-onboarding-flow

New Onboarding flow
abelanger5 4 лет назад
Родитель
Сommit
1719783e11
100 измененных файлов с 6862 добавлено и 806 удалено
  1. 4 1
      .dockerignore
  2. 3 0
      Makefile
  3. 24 0
      api/server/handlers/credentials/get_credentials_ce.go
  4. 21 0
      api/server/handlers/credentials/get_credentials_ee.go
  5. 19 4
      api/server/handlers/gitinstallation/oauth_callback.go
  6. 1 1
      api/server/handlers/handler.go
  7. 170 92
      api/server/handlers/infra/delete.go
  8. 43 0
      api/server/handlers/infra/get_current.go
  9. 43 0
      api/server/handlers/infra/get_desired.go
  10. 12 2
      api/server/handlers/oauth_callback/digitalocean.go
  11. 10 2
      api/server/handlers/oauth_callback/slack.go
  12. 11 0
      api/server/handlers/project/create.go
  13. 52 0
      api/server/handlers/project/get_onboarding.go
  14. 83 0
      api/server/handlers/project/update_onboarding.go
  15. 6 1
      api/server/handlers/project_integration/create_aws.go
  16. 5 1
      api/server/handlers/project_integration/create_gcp.go
  17. 44 0
      api/server/handlers/project_integration/list_aws.go
  18. 46 0
      api/server/handlers/project_integration/list_do.go
  19. 44 0
      api/server/handlers/project_integration/list_gcp.go
  20. 69 0
      api/server/handlers/provision/helpers.go
  21. 39 19
      api/server/handlers/provision/provision_docr.go
  22. 40 19
      api/server/handlers/provision/provision_doks.go
  23. 38 17
      api/server/handlers/provision/provision_ecr.go
  24. 40 18
      api/server/handlers/provision/provision_eks.go
  25. 37 16
      api/server/handlers/provision/provision_gcr.go
  26. 41 17
      api/server/handlers/provision/provision_gke.go
  27. 9 2
      api/server/handlers/user/github_callback.go
  28. 9 2
      api/server/handlers/user/google_callback.go
  29. 26 0
      api/server/router/base.go
  30. 56 0
      api/server/router/infra.go
  31. 56 0
      api/server/router/project.go
  32. 81 0
      api/server/router/project_integration.go
  33. 6 1
      api/server/shared/config/config.go
  34. 12 3
      api/server/shared/config/env/envconfs.go
  35. 9 0
      api/server/shared/config/loader/init_ee.go
  36. 9 6
      api/server/shared/config/loader/loader.go
  37. 20 0
      api/types/infra.go
  38. 32 0
      api/types/project.go
  39. 24 4
      api/types/project_integration.go
  40. 4 0
      api/types/provision.go
  41. 13 0
      api/types/registry.go
  42. 2 1
      cmd/migrate/keyrotate/helpers_test.go
  43. 3 3
      cmd/migrate/keyrotate/rotate.go
  44. 3 3
      cmd/migrate/keyrotate/rotate_test.go
  45. 4 0
      cmd/migrate/main.go
  46. 12 0
      cmd/migrate/migrate_ce.go
  47. 40 0
      cmd/migrate/migrate_ee.go
  48. 2 1
      dashboard/.dockerignore
  49. 13 0
      dashboard/package-lock.json
  50. 2 1
      dashboard/package.json
  51. 13 11
      dashboard/src/components/Boilerplate.tsx
  52. 42 0
      dashboard/src/components/Breadcrumb.tsx
  53. 8 2
      dashboard/src/components/Button.tsx
  54. 28 0
      dashboard/src/components/PageIllustration.tsx
  55. 271 0
      dashboard/src/components/ProvisionerStatus.tsx
  56. 7 1
      dashboard/src/components/SaveButton.tsx
  57. 103 31
      dashboard/src/components/Selector.tsx
  58. 5 3
      dashboard/src/components/TitleSection.tsx
  59. 2 0
      dashboard/src/components/form-components/InputRow.tsx
  60. 3 0
      dashboard/src/components/form-components/SelectRow.tsx
  61. 3 2
      dashboard/src/components/form-components/UploadArea.tsx
  62. 4 7
      dashboard/src/main/Main.tsx
  63. 217 385
      dashboard/src/main/home/Home.tsx
  64. 194 0
      dashboard/src/main/home/ModalHandler.tsx
  65. 6 1
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  66. 5 2
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx
  67. 8 1
      dashboard/src/main/home/dashboard/Dashboard.tsx
  68. 0 3
      dashboard/src/main/home/integrations/create-integration/GCRForm.tsx
  69. 1 18
      dashboard/src/main/home/integrations/edit-integration/GCRForm.tsx
  70. 1 1
      dashboard/src/main/home/modals/ClusterInstructionsModal.tsx
  71. 1 1
      dashboard/src/main/home/modals/EditInviteOrCollaboratorModal.tsx
  72. 1 1
      dashboard/src/main/home/modals/IntegrationsInstructionsModal.tsx
  73. 15 15
      dashboard/src/main/home/modals/IntegrationsModal.tsx
  74. 9 7
      dashboard/src/main/home/modals/Modal.tsx
  75. 1 1
      dashboard/src/main/home/modals/NamespaceModal.tsx
  76. 72 0
      dashboard/src/main/home/modals/RedirectToOnboardingModal.tsx
  77. 1 1
      dashboard/src/main/home/modals/UpdateClusterModal.tsx
  78. 2 2
      dashboard/src/main/home/modals/UsageWarningModal.tsx
  79. 205 73
      dashboard/src/main/home/new-project/NewProject.tsx
  80. 146 0
      dashboard/src/main/home/onboarding/Onboarding.tsx
  81. 29 0
      dashboard/src/main/home/onboarding/Routes.tsx
  82. 183 0
      dashboard/src/main/home/onboarding/components/ProviderSelector.tsx
  83. 109 0
      dashboard/src/main/home/onboarding/components/RegistryImageList.tsx
  84. 131 0
      dashboard/src/main/home/onboarding/state/StateHandler.ts
  85. 297 0
      dashboard/src/main/home/onboarding/state/StepHandler.ts
  86. 152 0
      dashboard/src/main/home/onboarding/state/index.ts
  87. 169 0
      dashboard/src/main/home/onboarding/steps/ConnectRegistry/ConnectRegistry.tsx
  88. 25 0
      dashboard/src/main/home/onboarding/steps/ConnectRegistry/ConnectRegistryWrapper.tsx
  89. 180 0
      dashboard/src/main/home/onboarding/steps/ConnectRegistry/forms/FormFlow.tsx
  90. 250 0
      dashboard/src/main/home/onboarding/steps/ConnectRegistry/forms/_AWSRegistryForm.tsx
  91. 264 0
      dashboard/src/main/home/onboarding/steps/ConnectRegistry/forms/_DORegistryForm.tsx
  92. 245 0
      dashboard/src/main/home/onboarding/steps/ConnectRegistry/forms/_GCPRegistryForm.tsx
  93. 271 0
      dashboard/src/main/home/onboarding/steps/ConnectSource.tsx
  94. 214 0
      dashboard/src/main/home/onboarding/steps/ProvisionResources/ProvisionResources.tsx
  95. 30 0
      dashboard/src/main/home/onboarding/steps/ProvisionResources/ProvisionResourcesWrapper.tsx
  96. 172 0
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/FormFlow.tsx
  97. 353 0
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/SharedStatus.tsx
  98. 317 0
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_AWSProvisionerForm.tsx
  99. 327 0
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_ConnectExternalCluster.tsx
  100. 378 0
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_DOProvisionerForm.tsx

+ 4 - 1
.dockerignore

@@ -1 +1,4 @@
-/dashboard/node_modules
+/dashboard/node_modules
+.env
+docker/.env
+*.db

+ 3 - 0
Makefile

@@ -4,6 +4,9 @@ VERSION ?= dev
 start-dev: install setup-env-files
 start-dev: install setup-env-files
 	bash ./scripts/dev-environment/StartDevServer.sh
 	bash ./scripts/dev-environment/StartDevServer.sh
 
 
+run-migrate-dev: install setup-env-files
+	bash ./scripts/dev-environment/RunMigrateDev.sh
+
 install: 
 install: 
 	bash ./scripts/dev-environment/SetupEnvironment.sh
 	bash ./scripts/dev-environment/SetupEnvironment.sh
 
 

+ 24 - 0
api/server/handlers/credentials/get_credentials_ce.go

@@ -0,0 +1,24 @@
+// +build !ee
+
+package credentials
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+)
+
+type GetCredentialsHandler struct {
+	handlers.PorterHandlerReader
+	handlers.Unavailable
+}
+
+func NewGetCredentialsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) http.Handler {
+	return handlers.NewUnavailable(config, "get_credential")
+}

+ 21 - 0
api/server/handlers/credentials/get_credentials_ee.go

@@ -0,0 +1,21 @@
+// +build ee
+
+package credentials
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/ee/api/server/handlers/credentials"
+)
+
+var NewGetCredentialsHandler func(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) http.Handler
+
+func init() {
+	NewGetCredentialsHandler = credentials.NewCredentialsGetHandler
+}

+ 19 - 4
api/server/handlers/gitinstallation/oauth_callback.go

@@ -3,6 +3,7 @@ package gitinstallation
 import (
 import (
 	"fmt"
 	"fmt"
 	"net/http"
 	"net/http"
+	"net/url"
 
 
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -42,8 +43,15 @@ func (c *GithubAppOAuthCallbackHandler) ServeHTTP(w http.ResponseWriter, r *http
 	token, err := c.Config().GithubAppConf.Exchange(oauth2.NoContext, r.URL.Query().Get("code"))
 	token, err := c.Config().GithubAppConf.Exchange(oauth2.NoContext, r.URL.Query().Get("code"))
 
 
 	if err != nil || !token.Valid() {
 	if err != nil || !token.Valid() {
-		if session.Values["query_params"] != "" {
-			http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
+		if redirectStr, ok := session.Values["redirect_uri"].(string); ok && redirectStr != "" {
+			// attempt to parse the redirect uri, if it fails just redirect to dashboard
+			redirectURI, err := url.Parse(redirectStr)
+
+			if err != nil {
+				http.Redirect(w, r, "/dashboard", 302)
+			}
+
+			http.Redirect(w, r, fmt.Sprintf("%s?%s", redirectURI.Path, redirectURI.RawQuery), 302)
 		} else {
 		} else {
 			http.Redirect(w, r, "/dashboard", 302)
 			http.Redirect(w, r, "/dashboard", 302)
 		}
 		}
@@ -82,8 +90,15 @@ func (c *GithubAppOAuthCallbackHandler) ServeHTTP(w http.ResponseWriter, r *http
 		},
 		},
 	))
 	))
 
 
-	if session.Values["query_params"] != "" {
-		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
+	if redirectStr, ok := session.Values["redirect_uri"].(string); ok && redirectStr != "" {
+		// attempt to parse the redirect uri, if it fails just redirect to dashboard
+		redirectURI, err := url.Parse(redirectStr)
+
+		if err != nil {
+			http.Redirect(w, r, "/dashboard", 302)
+		}
+
+		http.Redirect(w, r, fmt.Sprintf("%s?%s", redirectURI.Path, redirectURI.RawQuery), 302)
 	} else {
 	} else {
 		http.Redirect(w, r, "/dashboard", 302)
 		http.Redirect(w, r, "/dashboard", 302)
 	}
 	}

+ 1 - 1
api/server/handlers/handler.go

@@ -90,7 +90,7 @@ func (d *DefaultPorterHandler) PopulateOAuthSession(w http.ResponseWriter, r *ht
 
 
 	// need state parameter to validate when redirected
 	// need state parameter to validate when redirected
 	session.Values["state"] = state
 	session.Values["state"] = state
-	session.Values["query_params"] = r.URL.RawQuery
+	session.Values["redirect_uri"] = r.URL.Query().Get("redirect_uri")
 
 
 	if isProject {
 	if isProject {
 		project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 		project, _ := r.Context().Value(types.ProjectScope).(*models.Project)

+ 170 - 92
api/server/handlers/infra/delete.go

@@ -1,18 +1,23 @@
 package infra
 package infra
 
 
 import (
 import (
+	"encoding/json"
 	"net/http"
 	"net/http"
 
 
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/handlers/provision"
 	"github.com/porter-dev/porter/api/server/shared"
 	"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/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/analytics"
-	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/ecr"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/eks"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do/docr"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do/doks"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/gcp/gke"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
-	"github.com/porter-dev/porter/internal/repository"
 )
 )
 
 
 type InfraDeleteHandler struct {
 type InfraDeleteHandler struct {
@@ -58,15 +63,15 @@ func (c *InfraDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 
 	switch infra.Kind {
 	switch infra.Kind {
 	case types.InfraECR:
 	case types.InfraECR:
-		err = destroyECR(c.Repo(), c.Config(), infra, request.Name)
+		err = destroyECR(c.Config(), infra)
 	case types.InfraEKS:
 	case types.InfraEKS:
-		err = destroyEKS(c.Repo(), c.Config(), infra, request.Name)
+		err = destroyEKS(c.Config(), infra)
 	case types.InfraDOCR:
 	case types.InfraDOCR:
-		err = destroyDOCR(c.Repo(), c.Config(), infra, request.Name)
+		err = destroyDOCR(c.Config(), infra)
 	case types.InfraDOKS:
 	case types.InfraDOKS:
-		err = destroyDOKS(c.Repo(), c.Config(), infra, request.Name)
+		err = destroyDOKS(c.Config(), infra)
 	case types.InfraGKE:
 	case types.InfraGKE:
-		err = destroyGKE(c.Repo(), c.Config(), infra, request.Name)
+		err = destroyGKE(c.Config(), infra)
 	}
 	}
 
 
 	if err != nil {
 	if err != nil {
@@ -75,132 +80,205 @@ func (c *InfraDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	}
 	}
 }
 }
 
 
-func destroyECR(repo repository.Repository, conf *config.Config, infra *models.Infra, name string) error {
-	awsInt, err := repo.AWSIntegration().ReadAWSIntegration(infra.ProjectID, infra.AWSIntegrationID)
+func destroyECR(conf *config.Config, infra *models.Infra) error {
+	lastAppliedECR := &types.CreateECRInfraRequest{}
+
+	// parse infra last applied into ECR config
+	if err := json.Unmarshal(infra.LastApplied, lastAppliedECR); err != nil {
+		return err
+	}
+
+	awsInt, err := conf.Repo.AWSIntegration().ReadAWSIntegration(infra.ProjectID, infra.AWSIntegrationID)
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
 
 
-	_, err = conf.ProvisionerAgent.ProvisionECR(
-		&kubernetes.SharedProvisionOpts{
-			ProjectID:           infra.ProjectID,
-			Repo:                repo,
-			Infra:               infra,
-			Operation:           provisioner.Destroy,
-			PGConf:              conf.DBConf,
-			RedisConf:           conf.RedisConf,
-			ProvImageTag:        conf.ServerConf.ProvisionerImageTag,
-			ProvImagePullSecret: conf.ServerConf.ProvisionerImagePullSecret,
-		},
-		awsInt,
-		name,
-	)
+	opts, err := provision.GetSharedProvisionerOpts(conf, infra)
+
+	vaultToken := ""
+
+	if conf.CredentialBackend != nil {
+		vaultToken, err = conf.CredentialBackend.CreateAWSToken(awsInt)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	opts.CredentialExchange.VaultToken = vaultToken
+
+	opts.ECR = &ecr.Conf{
+		AWSRegion: awsInt.AWSRegion,
+		ECRName:   lastAppliedECR.ECRName,
+	}
+
+	opts.OperationKind = provisioner.Destroy
+
+	err = conf.ProvisionerAgent.Provision(opts)
 
 
 	return err
 	return err
 }
 }
 
 
-func destroyEKS(repo repository.Repository, conf *config.Config, infra *models.Infra, name string) error {
-	awsInt, err := repo.AWSIntegration().ReadAWSIntegration(infra.ProjectID, infra.AWSIntegrationID)
+func destroyEKS(conf *config.Config, infra *models.Infra) error {
+	lastAppliedEKS := &types.CreateEKSInfraRequest{}
+
+	// parse infra last applied into EKS config
+	if err := json.Unmarshal(infra.LastApplied, lastAppliedEKS); err != nil {
+		return err
+	}
+
+	awsInt, err := conf.Repo.AWSIntegration().ReadAWSIntegration(infra.ProjectID, infra.AWSIntegrationID)
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
 
 
-	_, err = conf.ProvisionerAgent.ProvisionEKS(
-		&kubernetes.SharedProvisionOpts{
-			ProjectID:           infra.ProjectID,
-			Repo:                repo,
-			Infra:               infra,
-			Operation:           provisioner.Destroy,
-			PGConf:              conf.DBConf,
-			RedisConf:           conf.RedisConf,
-			ProvImageTag:        conf.ServerConf.ProvisionerImageTag,
-			ProvImagePullSecret: conf.ServerConf.ProvisionerImagePullSecret,
-		},
-		awsInt,
-		name,
-		"",
-	)
+	opts, err := provision.GetSharedProvisionerOpts(conf, infra)
+
+	vaultToken := ""
+
+	if conf.CredentialBackend != nil {
+		vaultToken, err = conf.CredentialBackend.CreateAWSToken(awsInt)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	opts.CredentialExchange.VaultToken = vaultToken
+
+	opts.EKS = &eks.Conf{
+		AWSRegion:   awsInt.AWSRegion,
+		ClusterName: lastAppliedEKS.EKSName,
+		MachineType: lastAppliedEKS.MachineType,
+		IssuerEmail: lastAppliedEKS.IssuerEmail,
+	}
+	opts.OperationKind = provisioner.Destroy
+
+	err = conf.ProvisionerAgent.Provision(opts)
 
 
 	return err
 	return err
 }
 }
 
 
-func destroyDOCR(repo repository.Repository, conf *config.Config, infra *models.Infra, name string) error {
-	doInt, err := repo.OAuthIntegration().ReadOAuthIntegration(infra.ProjectID, infra.DOIntegrationID)
+func destroyDOCR(conf *config.Config, infra *models.Infra) error {
+	lastAppliedDOCR := &types.CreateDOCRInfraRequest{}
+
+	// parse infra last applied into DOCR config
+	if err := json.Unmarshal(infra.LastApplied, lastAppliedDOCR); err != nil {
+		return err
+	}
+
+	doInt, err := conf.Repo.OAuthIntegration().ReadOAuthIntegration(infra.ProjectID, infra.DOIntegrationID)
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
 
 
-	_, err = conf.ProvisionerAgent.ProvisionDOCR(
-		&kubernetes.SharedProvisionOpts{
-			ProjectID:           infra.ProjectID,
-			Repo:                repo,
-			Infra:               infra,
-			Operation:           provisioner.Destroy,
-			PGConf:              conf.DBConf,
-			RedisConf:           conf.RedisConf,
-			ProvImageTag:        conf.ServerConf.ProvisionerImageTag,
-			ProvImagePullSecret: conf.ServerConf.ProvisionerImagePullSecret,
-		},
-		doInt,
-		conf.DOConf,
-		name,
-		"",
-	)
+	opts, err := provision.GetSharedProvisionerOpts(conf, infra)
+
+	vaultToken := ""
+
+	if conf.CredentialBackend != nil {
+		vaultToken, err = conf.CredentialBackend.CreateOAuthToken(doInt)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	opts.CredentialExchange.VaultToken = vaultToken
+
+	opts.DOCR = &docr.Conf{
+		DOCRName:             lastAppliedDOCR.DOCRName,
+		DOCRSubscriptionTier: lastAppliedDOCR.DOCRSubscriptionTier,
+	}
+
+	opts.OperationKind = provisioner.Destroy
+
+	err = conf.ProvisionerAgent.Provision(opts)
 
 
 	return err
 	return err
 }
 }
 
 
-func destroyDOKS(repo repository.Repository, conf *config.Config, infra *models.Infra, name string) error {
-	doInt, err := repo.OAuthIntegration().ReadOAuthIntegration(infra.ProjectID, infra.DOIntegrationID)
+func destroyDOKS(conf *config.Config, infra *models.Infra) error {
+	lastAppliedDOKS := &types.CreateDOKSInfraRequest{}
+
+	// parse infra last applied into DOKS config
+	if err := json.Unmarshal(infra.LastApplied, lastAppliedDOKS); err != nil {
+		return err
+	}
+
+	doInt, err := conf.Repo.OAuthIntegration().ReadOAuthIntegration(infra.ProjectID, infra.DOIntegrationID)
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
 
 
-	_, err = conf.ProvisionerAgent.ProvisionDOKS(
-		&kubernetes.SharedProvisionOpts{
-			ProjectID:           infra.ProjectID,
-			Repo:                repo,
-			Infra:               infra,
-			Operation:           provisioner.Destroy,
-			PGConf:              conf.DBConf,
-			RedisConf:           conf.RedisConf,
-			ProvImageTag:        conf.ServerConf.ProvisionerImageTag,
-			ProvImagePullSecret: conf.ServerConf.ProvisionerImagePullSecret,
-		},
-		doInt,
-		conf.DOConf,
-		"",
-		name,
-	)
+	opts, err := provision.GetSharedProvisionerOpts(conf, infra)
+
+	vaultToken := ""
+
+	if conf.CredentialBackend != nil {
+		vaultToken, err = conf.CredentialBackend.CreateOAuthToken(doInt)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	opts.CredentialExchange.VaultToken = vaultToken
+
+	opts.DOKS = &doks.Conf{
+		DORegion:        lastAppliedDOKS.DORegion,
+		DOKSClusterName: lastAppliedDOKS.DOKSName,
+		IssuerEmail:     lastAppliedDOKS.IssuerEmail,
+	}
+
+	opts.OperationKind = provisioner.Destroy
+
+	err = conf.ProvisionerAgent.Provision(opts)
 
 
 	return err
 	return err
 }
 }
 
 
-func destroyGKE(repo repository.Repository, conf *config.Config, infra *models.Infra, name string) error {
-	gcpInt, err := repo.GCPIntegration().ReadGCPIntegration(infra.ProjectID, infra.GCPIntegrationID)
+func destroyGKE(conf *config.Config, infra *models.Infra) error {
+	lastAppliedGKE := &types.CreateGKEInfraRequest{}
+
+	// parse infra last applied into DOKS config
+	if err := json.Unmarshal(infra.LastApplied, lastAppliedGKE); err != nil {
+		return err
+	}
+
+	gcpInt, err := conf.Repo.GCPIntegration().ReadGCPIntegration(infra.ProjectID, infra.GCPIntegrationID)
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
 
 
-	_, err = conf.ProvisionerAgent.ProvisionGKE(
-		&kubernetes.SharedProvisionOpts{
-			ProjectID:           infra.ProjectID,
-			Repo:                repo,
-			Infra:               infra,
-			Operation:           provisioner.Destroy,
-			PGConf:              conf.DBConf,
-			RedisConf:           conf.RedisConf,
-			ProvImageTag:        conf.ServerConf.ProvisionerImageTag,
-			ProvImagePullSecret: conf.ServerConf.ProvisionerImagePullSecret,
-		},
-		gcpInt,
-		name,
-	)
+	opts, err := provision.GetSharedProvisionerOpts(conf, infra)
+
+	vaultToken := ""
+
+	if conf.CredentialBackend != nil {
+		vaultToken, err = conf.CredentialBackend.CreateGCPToken(gcpInt)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	opts.CredentialExchange.VaultToken = vaultToken
+	opts.GKE = &gke.Conf{
+		GCPProjectID: gcpInt.GCPProjectID,
+		GCPRegion:    lastAppliedGKE.GCPRegion,
+		ClusterName:  lastAppliedGKE.GKEName,
+		IssuerEmail:  lastAppliedGKE.IssuerEmail,
+	}
+
+	opts.OperationKind = provisioner.Destroy
+
+	err = conf.ProvisionerAgent.Provision(opts)
 
 
 	return err
 	return err
 }
 }

+ 43 - 0
api/server/handlers/infra/get_current.go

@@ -0,0 +1,43 @@
+package infra
+
+import (
+	"net/http"
+
+	"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/ee/integrations/httpbackend"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type InfraGetCurrentHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewInfraGetCurrentHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *InfraGetCurrentHandler {
+	return &InfraGetCurrentHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *InfraGetCurrentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	infra, _ := r.Context().Value(types.InfraScope).(*models.Infra)
+
+	// TODO: move client out of this call
+	client := httpbackend.NewClient(c.Config().ServerConf.ProvisionerBackendURL)
+
+	// get the unique infra name and query from the TF HTTP backend
+	current, err := client.GetCurrentState(infra.GetUniqueName())
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, current)
+}

+ 43 - 0
api/server/handlers/infra/get_desired.go

@@ -0,0 +1,43 @@
+package infra
+
+import (
+	"net/http"
+
+	"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/ee/integrations/httpbackend"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type InfraGetDesiredHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewInfraGetDesiredHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *InfraGetDesiredHandler {
+	return &InfraGetDesiredHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *InfraGetDesiredHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	infra, _ := r.Context().Value(types.InfraScope).(*models.Infra)
+
+	// TODO: move client out of this call
+	client := httpbackend.NewClient(c.Config().ServerConf.ProvisionerBackendURL)
+
+	// get the unique infra name and query from the TF HTTP backend
+	desired, err := client.GetDesiredState(infra.GetUniqueName())
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, desired)
+}

+ 12 - 2
api/server/handlers/oauth_callback/digitalocean.go

@@ -3,6 +3,7 @@ package oauth_callback
 import (
 import (
 	"fmt"
 	"fmt"
 	"net/http"
 	"net/http"
+	"net/url"
 
 
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -70,6 +71,8 @@ func (p *OAuthCallbackDOHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		ProjectID: projID,
 		ProjectID: projID,
 	}
 	}
 
 
+	oauthInt.PopulateTargetMetadata()
+
 	// create the oauth integration first
 	// create the oauth integration first
 	oauthInt, err = p.Repo().OAuthIntegration().CreateOAuthIntegration(oauthInt)
 	oauthInt, err = p.Repo().OAuthIntegration().CreateOAuthIntegration(oauthInt)
 
 
@@ -78,8 +81,15 @@ func (p *OAuthCallbackDOHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		return
 		return
 	}
 	}
 
 
-	if session.Values["query_params"] != "" {
-		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
+	if redirectStr, ok := session.Values["redirect_uri"].(string); ok && redirectStr != "" {
+		// attempt to parse the redirect uri, if it fails just redirect to dashboard
+		redirectURI, err := url.Parse(redirectStr)
+
+		if err != nil {
+			http.Redirect(w, r, "/dashboard", 302)
+		}
+
+		http.Redirect(w, r, fmt.Sprintf("%s?%s", redirectURI.Path, redirectURI.RawQuery), 302)
 	} else {
 	} else {
 		http.Redirect(w, r, "/dashboard", 302)
 		http.Redirect(w, r, "/dashboard", 302)
 	}
 	}

+ 10 - 2
api/server/handlers/oauth_callback/slack.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"context"
 	"fmt"
 	"fmt"
 	"net/http"
 	"net/http"
+	"net/url"
 
 
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -69,8 +70,15 @@ func (p *OAuthCallbackSlackHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 		return
 		return
 	}
 	}
 
 
-	if session.Values["query_params"] != "" {
-		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
+	if redirectStr, ok := session.Values["redirect_uri"].(string); ok && redirectStr != "" {
+		// attempt to parse the redirect uri, if it fails just redirect to dashboard
+		redirectURI, err := url.Parse(redirectStr)
+
+		if err != nil {
+			http.Redirect(w, r, "/dashboard", 302)
+		}
+
+		http.Redirect(w, r, fmt.Sprintf("%s?%s", redirectURI.Path, redirectURI.RawQuery), 302)
 	} else {
 	} else {
 		http.Redirect(w, r, "/dashboard", 302)
 		http.Redirect(w, r, "/dashboard", 302)
 	}
 	}

+ 11 - 0
api/server/handlers/project/create.go

@@ -51,6 +51,17 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
+	// create onboarding flow set to the first step
+	_, err = p.Repo().Onboarding().CreateProjectOnboarding(&models.Onboarding{
+		ProjectID:   proj.ID,
+		CurrentStep: types.StepConnectSource,
+	})
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	// create default project usage restriction
 	// create default project usage restriction
 	_, err = p.Repo().ProjectUsage().CreateProjectUsage(&models.ProjectUsage{
 	_, err = p.Repo().ProjectUsage().CreateProjectUsage(&models.ProjectUsage{
 		ProjectID:      proj.ID,
 		ProjectID:      proj.ID,

+ 52 - 0
api/server/handlers/project/get_onboarding.go

@@ -0,0 +1,52 @@
+package project
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type OnboardingGetHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewOnboardingGetHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *OnboardingGetHandler {
+	return &OnboardingGetHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *OnboardingGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	// look for onboarding
+	onboarding, err := p.Repo().Onboarding().ReadProjectOnboarding(proj.ID)
+	isNotFound := errors.Is(gorm.ErrRecordNotFound, err)
+
+	if isNotFound {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("project onboarding data not found"),
+			http.StatusNotFound,
+		))
+
+		return
+	} else if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// return onboarding data type
+	p.WriteResult(w, r, onboarding.ToOnboardingType())
+}

+ 83 - 0
api/server/handlers/project/update_onboarding.go

@@ -0,0 +1,83 @@
+package project
+
+import (
+	"errors"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type OnboardingUpdateHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewOnboardingUpdateHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *OnboardingUpdateHandler {
+	return &OnboardingUpdateHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *OnboardingUpdateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	request := &types.UpdateOnboardingRequest{}
+
+	if ok := p.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	// look for onboarding
+	onboarding, err := p.Repo().Onboarding().ReadProjectOnboarding(proj.ID)
+	isNotFound := errors.Is(gorm.ErrRecordNotFound, err)
+
+	if err != nil && !isNotFound {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if isNotFound {
+		onboarding = &models.Onboarding{
+			ProjectID: proj.ID,
+		}
+	}
+
+	onboarding.CurrentStep = request.CurrentStep
+	onboarding.ConnectedSource = request.ConnectedSource
+	onboarding.SkipRegistryConnection = request.SkipRegistryConnection
+	onboarding.SkipResourceProvision = request.SkipResourceProvision
+	onboarding.RegistryConnectionID = request.RegistryConnectionID
+	onboarding.RegistryConnectionCredentialID = request.RegistryConnectionCredentialID
+	onboarding.RegistryConnectionProvider = request.RegistryConnectionProvider
+	onboarding.RegistryInfraID = request.RegistryInfraID
+	onboarding.RegistryInfraCredentialID = request.RegistryInfraCredentialID
+	onboarding.RegistryInfraProvider = request.RegistryInfraProvider
+	onboarding.ClusterInfraID = request.ClusterInfraID
+	onboarding.ClusterInfraCredentialID = request.ClusterInfraCredentialID
+	onboarding.ClusterInfraProvider = request.ClusterInfraProvider
+
+	if isNotFound {
+		// if not found, create onboarding struct
+		onboarding, err = p.Repo().Onboarding().CreateProjectOnboarding(onboarding)
+	} else {
+		// otherwise, update the onboarding model
+		onboarding, err = p.Repo().Onboarding().UpdateProjectOnboarding(onboarding)
+	}
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// return onboarding data type
+	p.WriteResult(w, r, onboarding.ToOnboardingType())
+}

+ 6 - 1
api/server/handlers/project_integration/create_aws.go

@@ -53,7 +53,7 @@ func (p *CreateAWSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 }
 }
 
 
 func CreateAWSIntegration(request *types.CreateAWSRequest, projectID, userID uint) *ints.AWSIntegration {
 func CreateAWSIntegration(request *types.CreateAWSRequest, projectID, userID uint) *ints.AWSIntegration {
-	return &ints.AWSIntegration{
+	resp := &ints.AWSIntegration{
 		UserID:             userID,
 		UserID:             userID,
 		ProjectID:          projectID,
 		ProjectID:          projectID,
 		AWSRegion:          request.AWSRegion,
 		AWSRegion:          request.AWSRegion,
@@ -61,4 +61,9 @@ func CreateAWSIntegration(request *types.CreateAWSRequest, projectID, userID uin
 		AWSAccessKeyID:     []byte(request.AWSAccessKeyID),
 		AWSAccessKeyID:     []byte(request.AWSAccessKeyID),
 		AWSSecretAccessKey: []byte(request.AWSSecretAccessKey),
 		AWSSecretAccessKey: []byte(request.AWSSecretAccessKey),
 	}
 	}
+
+	// attempt to populate the ARN
+	resp.PopulateAWSArn()
+
+	return resp
 }
 }

+ 5 - 1
api/server/handlers/project_integration/create_gcp.go

@@ -53,11 +53,15 @@ func (p *CreateGCPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 }
 }
 
 
 func CreateGCPIntegration(request *types.CreateGCPRequest, projectID, userID uint) *ints.GCPIntegration {
 func CreateGCPIntegration(request *types.CreateGCPRequest, projectID, userID uint) *ints.GCPIntegration {
-	return &ints.GCPIntegration{
+	resp := &ints.GCPIntegration{
 		UserID:       userID,
 		UserID:       userID,
 		ProjectID:    projectID,
 		ProjectID:    projectID,
 		GCPKeyData:   []byte(request.GCPKeyData),
 		GCPKeyData:   []byte(request.GCPKeyData),
 		GCPProjectID: request.GCPProjectID,
 		GCPProjectID: request.GCPProjectID,
 		GCPRegion:    request.GCPRegion,
 		GCPRegion:    request.GCPRegion,
 	}
 	}
+
+	resp.PopulateGCPMetadata()
+
+	return resp
 }
 }

+ 44 - 0
api/server/handlers/project_integration/list_aws.go

@@ -0,0 +1,44 @@
+package project_integration
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type ListAWSHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewListAWSHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *ListAWSHandler {
+	return &ListAWSHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (p *ListAWSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	awsInts, err := p.Repo().AWSIntegration().ListAWSIntegrationsByProjectID(project.ID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var res types.ListAWSResponse = make([]*types.AWSIntegration, 0)
+
+	for _, awsInt := range awsInts {
+		res = append(res, awsInt.ToAWSIntegrationType())
+	}
+
+	p.WriteResult(w, r, res)
+}

+ 46 - 0
api/server/handlers/project_integration/list_do.go

@@ -0,0 +1,46 @@
+package project_integration
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type ListDOHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewListDOHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *ListDOHandler {
+	return &ListDOHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (p *ListDOHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	oauthInts, err := p.Repo().OAuthIntegration().ListOAuthIntegrationsByProjectID(project.ID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var res types.ListOAuthResponse = make([]*types.OAuthIntegration, 0)
+
+	for _, oauthInt := range oauthInts {
+		if oauthInt.Client == types.OAuthDigitalOcean {
+			res = append(res, oauthInt.ToOAuthIntegrationType())
+		}
+	}
+
+	p.WriteResult(w, r, res)
+}

+ 44 - 0
api/server/handlers/project_integration/list_gcp.go

@@ -0,0 +1,44 @@
+package project_integration
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type ListGCPHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewListGCPHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *ListGCPHandler {
+	return &ListGCPHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (p *ListGCPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	gcpInts, err := p.Repo().GCPIntegration().ListGCPIntegrationsByProjectID(project.ID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var res types.ListGCPResponse = make([]*types.GCPIntegration, 0)
+
+	for _, gcpInt := range gcpInts {
+		res = append(res, gcpInt.ToGCPIntegrationType())
+	}
+
+	p.WriteResult(w, r, res)
+}

+ 69 - 0
api/server/handlers/provision/helpers.go

@@ -0,0 +1,69 @@
+package provision
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/random"
+	"golang.org/x/crypto/bcrypt"
+)
+
+func CreateCEToken(conf *config.Config, infra *models.Infra) (*models.CredentialsExchangeToken, string, error) {
+	// convert the form to a project model
+	expiry := time.Now().Add(6 * time.Hour)
+
+	rawToken, err := random.StringWithCharset(32, "")
+
+	if err != nil {
+		return nil, "", err
+	}
+
+	hashedToken, err := bcrypt.GenerateFromPassword([]byte(rawToken), 8)
+
+	if err != nil {
+		return nil, "", err
+	}
+
+	ceToken := &models.CredentialsExchangeToken{
+		ProjectID:       infra.ProjectID,
+		Expiry:          &expiry,
+		Token:           hashedToken,
+		DOCredentialID:  infra.DOIntegrationID,
+		AWSCredentialID: infra.AWSIntegrationID,
+		GCPCredentialID: infra.GCPIntegrationID,
+	}
+
+	// handle write to the database
+	ceToken, err = conf.Repo.CredentialsExchangeToken().CreateCredentialsExchangeToken(ceToken)
+
+	if err != nil {
+		return nil, "", err
+	}
+
+	return ceToken, rawToken, nil
+}
+
+func GetSharedProvisionerOpts(conf *config.Config, infra *models.Infra) (*provisioner.ProvisionOpts, error) {
+	ceToken, rawToken, err := CreateCEToken(conf, infra)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &provisioner.ProvisionOpts{
+		DryRun:              true,
+		Infra:               infra,
+		ProvImageTag:        conf.ServerConf.ProvisionerImageTag,
+		ProvJobNamespace:    conf.ServerConf.ProvisionerJobNamespace,
+		ProvImagePullSecret: conf.ServerConf.ProvisionerImagePullSecret,
+		TFHTTPBackendURL:    conf.ServerConf.ProvisionerBackendURL,
+		CredentialExchange: &provisioner.ProvisionCredentialExchange{
+			CredExchangeEndpoint: fmt.Sprintf("%s/api/internal/credentials", conf.ServerConf.ServerURL),
+			CredExchangeToken:    rawToken,
+			CredExchangeID:       ceToken.ID,
+		},
+	}, nil
+}

+ 39 - 19
api/server/handlers/provision/provision_docr.go

@@ -1,6 +1,7 @@
 package provision
 package provision
 
 
 import (
 import (
+	"encoding/json"
 	"net/http"
 	"net/http"
 
 
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -9,8 +10,8 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/analytics"
-	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do/docr"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/repository"
 	"gorm.io/gorm"
 	"gorm.io/gorm"
@@ -43,7 +44,7 @@ func (c *ProvisionDOCRHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
-	// get the AWS integration
+	// get the DO integration, to check that integration exists and belongs to the project
 	doInt, err := c.Repo().OAuthIntegration().ReadOAuthIntegration(proj.ID, request.DOIntegrationID)
 	doInt, err := c.Repo().OAuthIntegration().ReadOAuthIntegration(proj.ID, request.DOIntegrationID)
 
 
 	if err != nil {
 	if err != nil {
@@ -63,6 +64,14 @@ func (c *ProvisionDOCRHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
+	lastApplied, err := json.Marshal(request)
+
+	// parse infra last applied into DOCR config
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	infra := &models.Infra{
 	infra := &models.Infra{
 		Kind:            types.InfraDOCR,
 		Kind:            types.InfraDOCR,
 		ProjectID:       proj.ID,
 		ProjectID:       proj.ID,
@@ -70,6 +79,7 @@ func (c *ProvisionDOCRHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		Status:          types.StatusCreating,
 		Status:          types.StatusCreating,
 		DOIntegrationID: request.DOIntegrationID,
 		DOIntegrationID: request.DOIntegrationID,
 		CreatedByUserID: user.ID,
 		CreatedByUserID: user.ID,
+		LastApplied:     lastApplied,
 	}
 	}
 
 
 	// handle write to the database
 	// handle write to the database
@@ -80,23 +90,33 @@ func (c *ProvisionDOCRHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
-	// launch provisioning pod
-	_, err = c.Config().ProvisionerAgent.ProvisionDOCR(
-		&kubernetes.SharedProvisionOpts{
-			ProjectID:           proj.ID,
-			Repo:                c.Repo(),
-			Infra:               infra,
-			Operation:           provisioner.Apply,
-			PGConf:              c.Config().DBConf,
-			RedisConf:           c.Config().RedisConf,
-			ProvImageTag:        c.Config().ServerConf.ProvisionerImageTag,
-			ProvImagePullSecret: c.Config().ServerConf.ProvisionerImagePullSecret,
-		},
-		doInt,
-		c.Config().DOConf,
-		request.DOCRName,
-		request.DOCRSubscriptionTier,
-	)
+	opts, err := GetSharedProvisionerOpts(c.Config(), infra)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	vaultToken := ""
+
+	if c.Config().CredentialBackend != nil {
+		vaultToken, err = c.Config().CredentialBackend.CreateOAuthToken(doInt)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	opts.CredentialExchange.VaultToken = vaultToken
+	opts.DOCR = &docr.Conf{
+		DOCRName:             request.DOCRName,
+		DOCRSubscriptionTier: request.DOCRSubscriptionTier,
+	}
+
+	opts.OperationKind = provisioner.Apply
+
+	err = c.Config().ProvisionerAgent.Provision(opts)
 
 
 	if err != nil {
 	if err != nil {
 		infra.Status = types.StatusError
 		infra.Status = types.StatusError

+ 40 - 19
api/server/handlers/provision/provision_doks.go

@@ -1,6 +1,7 @@
 package provision
 package provision
 
 
 import (
 import (
+	"encoding/json"
 	"net/http"
 	"net/http"
 
 
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -9,8 +10,8 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/analytics"
-	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do/doks"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/repository"
 	"gorm.io/gorm"
 	"gorm.io/gorm"
@@ -43,7 +44,7 @@ func (c *ProvisionDOKSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
-	// get the AWS integration
+	// get the DO integration, to check that integration exists and belongs to the project
 	doInt, err := c.Repo().OAuthIntegration().ReadOAuthIntegration(proj.ID, request.DOIntegrationID)
 	doInt, err := c.Repo().OAuthIntegration().ReadOAuthIntegration(proj.ID, request.DOIntegrationID)
 
 
 	if err != nil {
 	if err != nil {
@@ -63,6 +64,14 @@ func (c *ProvisionDOKSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
+	lastApplied, err := json.Marshal(request)
+
+	// parse infra last applied into DOKS config
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	infra := &models.Infra{
 	infra := &models.Infra{
 		Kind:            types.InfraDOKS,
 		Kind:            types.InfraDOKS,
 		ProjectID:       proj.ID,
 		ProjectID:       proj.ID,
@@ -70,6 +79,7 @@ func (c *ProvisionDOKSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		Status:          types.StatusCreating,
 		Status:          types.StatusCreating,
 		DOIntegrationID: request.DOIntegrationID,
 		DOIntegrationID: request.DOIntegrationID,
 		CreatedByUserID: user.ID,
 		CreatedByUserID: user.ID,
+		LastApplied:     lastApplied,
 	}
 	}
 
 
 	// handle write to the database
 	// handle write to the database
@@ -80,23 +90,34 @@ func (c *ProvisionDOKSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
-	// launch provisioning pod
-	_, err = c.Config().ProvisionerAgent.ProvisionDOKS(
-		&kubernetes.SharedProvisionOpts{
-			ProjectID:           proj.ID,
-			Repo:                c.Repo(),
-			Infra:               infra,
-			Operation:           provisioner.Apply,
-			PGConf:              c.Config().DBConf,
-			RedisConf:           c.Config().RedisConf,
-			ProvImageTag:        c.Config().ServerConf.ProvisionerImageTag,
-			ProvImagePullSecret: c.Config().ServerConf.ProvisionerImagePullSecret,
-		},
-		doInt,
-		c.Config().DOConf,
-		request.DORegion,
-		request.DOKSName,
-	)
+	opts, err := GetSharedProvisionerOpts(c.Config(), infra)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	vaultToken := ""
+
+	if c.Config().CredentialBackend != nil {
+		vaultToken, err = c.Config().CredentialBackend.CreateOAuthToken(doInt)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	opts.CredentialExchange.VaultToken = vaultToken
+	opts.DOKS = &doks.Conf{
+		DORegion:        request.DORegion,
+		DOKSClusterName: request.DOKSName,
+		IssuerEmail:     request.IssuerEmail,
+	}
+
+	opts.OperationKind = provisioner.Apply
+
+	err = c.Config().ProvisionerAgent.Provision(opts)
 
 
 	if err != nil {
 	if err != nil {
 		infra.Status = types.StatusError
 		infra.Status = types.StatusError

+ 38 - 17
api/server/handlers/provision/provision_ecr.go

@@ -1,6 +1,7 @@
 package provision
 package provision
 
 
 import (
 import (
+	"encoding/json"
 	"net/http"
 	"net/http"
 
 
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -9,8 +10,8 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/analytics"
-	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/ecr"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/repository"
 	"gorm.io/gorm"
 	"gorm.io/gorm"
@@ -43,7 +44,7 @@ func (c *ProvisionECRHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
-	// get the AWS integration
+	// get the AWS integration, to check that integration exists and belongs to the project
 	awsInt, err := c.Repo().AWSIntegration().ReadAWSIntegration(proj.ID, request.AWSIntegrationID)
 	awsInt, err := c.Repo().AWSIntegration().ReadAWSIntegration(proj.ID, request.AWSIntegrationID)
 
 
 	if err != nil {
 	if err != nil {
@@ -63,6 +64,14 @@ func (c *ProvisionECRHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
+	lastApplied, err := json.Marshal(request)
+
+	// parse infra last applied into ECR config
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	infra := &models.Infra{
 	infra := &models.Infra{
 		Kind:             types.InfraECR,
 		Kind:             types.InfraECR,
 		ProjectID:        proj.ID,
 		ProjectID:        proj.ID,
@@ -70,6 +79,7 @@ func (c *ProvisionECRHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		Status:           types.StatusCreating,
 		Status:           types.StatusCreating,
 		AWSIntegrationID: request.AWSIntegrationID,
 		AWSIntegrationID: request.AWSIntegrationID,
 		CreatedByUserID:  user.ID,
 		CreatedByUserID:  user.ID,
+		LastApplied:      lastApplied,
 	}
 	}
 
 
 	// handle write to the database
 	// handle write to the database
@@ -80,21 +90,32 @@ func (c *ProvisionECRHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
-	// launch provisioning pod
-	_, err = c.Config().ProvisionerAgent.ProvisionECR(
-		&kubernetes.SharedProvisionOpts{
-			ProjectID:           proj.ID,
-			Repo:                c.Repo(),
-			Infra:               infra,
-			Operation:           provisioner.Apply,
-			PGConf:              c.Config().DBConf,
-			RedisConf:           c.Config().RedisConf,
-			ProvImageTag:        c.Config().ServerConf.ProvisionerImageTag,
-			ProvImagePullSecret: c.Config().ServerConf.ProvisionerImagePullSecret,
-		},
-		awsInt,
-		request.ECRName,
-	)
+	opts, err := GetSharedProvisionerOpts(c.Config(), infra)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	vaultToken := ""
+
+	if c.Config().CredentialBackend != nil {
+		vaultToken, err = c.Config().CredentialBackend.CreateAWSToken(awsInt)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	opts.CredentialExchange.VaultToken = vaultToken
+	opts.ECR = &ecr.Conf{
+		AWSRegion: awsInt.AWSRegion,
+		ECRName:   request.ECRName,
+	}
+	opts.OperationKind = provisioner.Apply
+
+	err = c.Config().ProvisionerAgent.Provision(opts)
 
 
 	if err != nil {
 	if err != nil {
 		infra.Status = types.StatusError
 		infra.Status = types.StatusError

+ 40 - 18
api/server/handlers/provision/provision_eks.go

@@ -1,6 +1,7 @@
 package provision
 package provision
 
 
 import (
 import (
+	"encoding/json"
 	"net/http"
 	"net/http"
 
 
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -9,8 +10,8 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/analytics"
-	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/eks"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/repository"
 	"gorm.io/gorm"
 	"gorm.io/gorm"
@@ -43,7 +44,7 @@ func (c *ProvisionEKSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
-	// get the AWS integration
+	// get the AWS integration, to check that integration exists and belongs to the project
 	awsInt, err := c.Repo().AWSIntegration().ReadAWSIntegration(proj.ID, request.AWSIntegrationID)
 	awsInt, err := c.Repo().AWSIntegration().ReadAWSIntegration(proj.ID, request.AWSIntegrationID)
 
 
 	if err != nil {
 	if err != nil {
@@ -63,6 +64,14 @@ func (c *ProvisionEKSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
+	lastApplied, err := json.Marshal(request)
+
+	// parse infra last applied into EKS config
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	infra := &models.Infra{
 	infra := &models.Infra{
 		Kind:             types.InfraEKS,
 		Kind:             types.InfraEKS,
 		ProjectID:        proj.ID,
 		ProjectID:        proj.ID,
@@ -70,6 +79,7 @@ func (c *ProvisionEKSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		Status:           types.StatusCreating,
 		Status:           types.StatusCreating,
 		AWSIntegrationID: request.AWSIntegrationID,
 		AWSIntegrationID: request.AWSIntegrationID,
 		CreatedByUserID:  user.ID,
 		CreatedByUserID:  user.ID,
+		LastApplied:      lastApplied,
 	}
 	}
 
 
 	// handle write to the database
 	// handle write to the database
@@ -80,22 +90,34 @@ func (c *ProvisionEKSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
-	// launch provisioning pod
-	_, err = c.Config().ProvisionerAgent.ProvisionEKS(
-		&kubernetes.SharedProvisionOpts{
-			ProjectID:           proj.ID,
-			Repo:                c.Repo(),
-			Infra:               infra,
-			Operation:           provisioner.Apply,
-			PGConf:              c.Config().DBConf,
-			RedisConf:           c.Config().RedisConf,
-			ProvImageTag:        c.Config().ServerConf.ProvisionerImageTag,
-			ProvImagePullSecret: c.Config().ServerConf.ProvisionerImagePullSecret,
-		},
-		awsInt,
-		request.EKSName,
-		request.MachineType,
-	)
+	opts, err := GetSharedProvisionerOpts(c.Config(), infra)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	vaultToken := ""
+
+	if c.Config().CredentialBackend != nil {
+		vaultToken, err = c.Config().CredentialBackend.CreateAWSToken(awsInt)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	opts.CredentialExchange.VaultToken = vaultToken
+	opts.EKS = &eks.Conf{
+		AWSRegion:   awsInt.AWSRegion,
+		ClusterName: request.EKSName,
+		MachineType: request.MachineType,
+		IssuerEmail: request.IssuerEmail,
+	}
+	opts.OperationKind = provisioner.Apply
+
+	err = c.Config().ProvisionerAgent.Provision(opts)
 
 
 	if err != nil {
 	if err != nil {
 		infra.Status = types.StatusError
 		infra.Status = types.StatusError

+ 37 - 16
api/server/handlers/provision/provision_gcr.go

@@ -1,6 +1,7 @@
 package provision
 package provision
 
 
 import (
 import (
+	"encoding/json"
 	"net/http"
 	"net/http"
 
 
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -9,8 +10,8 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/analytics"
-	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/gcp/gcr"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/repository"
 	"gorm.io/gorm"
 	"gorm.io/gorm"
@@ -43,7 +44,7 @@ func (c *ProvisionGCRHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
-	// get the AWS integration
+	// get the GCP integration, to check that integration exists and belongs to the project
 	gcpInt, err := c.Repo().GCPIntegration().ReadGCPIntegration(proj.ID, request.GCPIntegrationID)
 	gcpInt, err := c.Repo().GCPIntegration().ReadGCPIntegration(proj.ID, request.GCPIntegrationID)
 
 
 	if err != nil {
 	if err != nil {
@@ -63,6 +64,14 @@ func (c *ProvisionGCRHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
+	lastApplied, err := json.Marshal(request)
+
+	// parse infra last applied into GCR config
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	infra := &models.Infra{
 	infra := &models.Infra{
 		Kind:             types.InfraGCR,
 		Kind:             types.InfraGCR,
 		ProjectID:        proj.ID,
 		ProjectID:        proj.ID,
@@ -70,6 +79,7 @@ func (c *ProvisionGCRHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		Status:           types.StatusCreating,
 		Status:           types.StatusCreating,
 		GCPIntegrationID: request.GCPIntegrationID,
 		GCPIntegrationID: request.GCPIntegrationID,
 		CreatedByUserID:  user.ID,
 		CreatedByUserID:  user.ID,
+		LastApplied:      lastApplied,
 	}
 	}
 
 
 	// handle write to the database
 	// handle write to the database
@@ -80,20 +90,31 @@ func (c *ProvisionGCRHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
-	// launch provisioning pod
-	_, err = c.Config().ProvisionerAgent.ProvisionGCR(
-		&kubernetes.SharedProvisionOpts{
-			ProjectID:           proj.ID,
-			Repo:                c.Repo(),
-			Infra:               infra,
-			Operation:           provisioner.Apply,
-			PGConf:              c.Config().DBConf,
-			RedisConf:           c.Config().RedisConf,
-			ProvImageTag:        c.Config().ServerConf.ProvisionerImageTag,
-			ProvImagePullSecret: c.Config().ServerConf.ProvisionerImagePullSecret,
-		},
-		gcpInt,
-	)
+	opts, err := GetSharedProvisionerOpts(c.Config(), infra)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	vaultToken := ""
+
+	if c.Config().CredentialBackend != nil {
+		vaultToken, err = c.Config().CredentialBackend.CreateGCPToken(gcpInt)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	opts.GCR = &gcr.Conf{
+		GCPProjectID: gcpInt.GCPProjectID,
+	}
+	opts.CredentialExchange.VaultToken = vaultToken
+	opts.OperationKind = provisioner.Apply
+
+	err = c.Config().ProvisionerAgent.Provision(opts)
 
 
 	if err != nil {
 	if err != nil {
 		infra.Status = types.StatusError
 		infra.Status = types.StatusError

+ 41 - 17
api/server/handlers/provision/provision_gke.go

@@ -1,6 +1,7 @@
 package provision
 package provision
 
 
 import (
 import (
+	"encoding/json"
 	"net/http"
 	"net/http"
 
 
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -9,8 +10,8 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/analytics"
-	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/gcp/gke"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/repository"
 	"gorm.io/gorm"
 	"gorm.io/gorm"
@@ -43,7 +44,7 @@ func (c *ProvisionGKEHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
-	// get the AWS integration
+	// get the GCP integration, to check that integration exists and belongs to the project
 	gcpInt, err := c.Repo().GCPIntegration().ReadGCPIntegration(proj.ID, request.GCPIntegrationID)
 	gcpInt, err := c.Repo().GCPIntegration().ReadGCPIntegration(proj.ID, request.GCPIntegrationID)
 
 
 	if err != nil {
 	if err != nil {
@@ -63,6 +64,14 @@ func (c *ProvisionGKEHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
+	lastApplied, err := json.Marshal(request)
+
+	// parse infra last applied into GKE config
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	infra := &models.Infra{
 	infra := &models.Infra{
 		Kind:             types.InfraGKE,
 		Kind:             types.InfraGKE,
 		ProjectID:        proj.ID,
 		ProjectID:        proj.ID,
@@ -70,6 +79,7 @@ func (c *ProvisionGKEHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		Status:           types.StatusCreating,
 		Status:           types.StatusCreating,
 		GCPIntegrationID: request.GCPIntegrationID,
 		GCPIntegrationID: request.GCPIntegrationID,
 		CreatedByUserID:  user.ID,
 		CreatedByUserID:  user.ID,
+		LastApplied:      lastApplied,
 	}
 	}
 
 
 	// handle write to the database
 	// handle write to the database
@@ -80,21 +90,35 @@ func (c *ProvisionGKEHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
-	// launch provisioning pod
-	_, err = c.Config().ProvisionerAgent.ProvisionGKE(
-		&kubernetes.SharedProvisionOpts{
-			ProjectID:           proj.ID,
-			Repo:                c.Repo(),
-			Infra:               infra,
-			Operation:           provisioner.Apply,
-			PGConf:              c.Config().DBConf,
-			RedisConf:           c.Config().RedisConf,
-			ProvImageTag:        c.Config().ServerConf.ProvisionerImageTag,
-			ProvImagePullSecret: c.Config().ServerConf.ProvisionerImagePullSecret,
-		},
-		gcpInt,
-		request.GKEName,
-	)
+	opts, err := GetSharedProvisionerOpts(c.Config(), infra)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	vaultToken := ""
+
+	if c.Config().CredentialBackend != nil {
+		vaultToken, err = c.Config().CredentialBackend.CreateGCPToken(gcpInt)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	opts.CredentialExchange.VaultToken = vaultToken
+	opts.GKE = &gke.Conf{
+		GCPProjectID: gcpInt.GCPProjectID,
+		GCPRegion:    request.GCPRegion,
+		ClusterName:  request.GKEName,
+		IssuerEmail:  request.IssuerEmail,
+	}
+
+	opts.OperationKind = provisioner.Apply
+
+	err = c.Config().ProvisionerAgent.Provision(opts)
 
 
 	if err != nil {
 	if err != nil {
 		infra.Status = types.StatusError
 		infra.Status = types.StatusError

+ 9 - 2
api/server/handlers/user/github_callback.go

@@ -88,8 +88,15 @@ func (p *UserOAuthGithubCallbackHandler) ServeHTTP(w http.ResponseWriter, r *htt
 		startEmailVerification(p.Config(), w, r, user)
 		startEmailVerification(p.Config(), w, r, user)
 	}
 	}
 
 
-	if session.Values["query_params"] != "" {
-		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
+	if redirectStr, ok := session.Values["redirect_uri"].(string); ok && redirectStr != "" {
+		// attempt to parse the redirect uri, if it fails just redirect to dashboard
+		redirectURI, err := url.Parse(redirectStr)
+
+		if err != nil {
+			http.Redirect(w, r, "/dashboard", 302)
+		}
+
+		http.Redirect(w, r, fmt.Sprintf("%s?%s", redirectURI.Path, redirectURI.RawQuery), 302)
 	} else {
 	} else {
 		http.Redirect(w, r, "/dashboard", 302)
 		http.Redirect(w, r, "/dashboard", 302)
 	}
 	}

+ 9 - 2
api/server/handlers/user/google_callback.go

@@ -91,8 +91,15 @@ func (p *UserOAuthGoogleCallbackHandler) ServeHTTP(w http.ResponseWriter, r *htt
 		startEmailVerification(p.Config(), w, r, user)
 		startEmailVerification(p.Config(), w, r, user)
 	}
 	}
 
 
-	if session.Values["query_params"] != "" {
-		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
+	if redirectStr, ok := session.Values["redirect_uri"].(string); ok && redirectStr != "" {
+		// attempt to parse the redirect uri, if it fails just redirect to dashboard
+		redirectURI, err := url.Parse(redirectStr)
+
+		if err != nil {
+			http.Redirect(w, r, "/dashboard", 302)
+		}
+
+		http.Redirect(w, r, fmt.Sprintf("%s?%s", redirectURI.Path, redirectURI.RawQuery), 302)
 	} else {
 	} else {
 		http.Redirect(w, r, "/dashboard", 302)
 		http.Redirect(w, r, "/dashboard", 302)
 	}
 	}

+ 26 - 0
api/server/router/base.go

@@ -2,6 +2,7 @@ package router
 
 
 import (
 import (
 	"github.com/go-chi/chi"
 	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/api/server/handlers/credentials"
 	"github.com/porter-dev/porter/api/server/handlers/gitinstallation"
 	"github.com/porter-dev/porter/api/server/handlers/gitinstallation"
 	"github.com/porter-dev/porter/api/server/handlers/healthcheck"
 	"github.com/porter-dev/porter/api/server/handlers/healthcheck"
 	"github.com/porter-dev/porter/api/server/handlers/metadata"
 	"github.com/porter-dev/porter/api/server/handlers/metadata"
@@ -485,5 +486,30 @@ func GetBaseRoutes(
 		Router:   r,
 		Router:   r,
 	})
 	})
 
 
+	// GET /api/internal/credentials
+	getCredentialsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/internal/credentials",
+			},
+			Scopes: []types.PermissionScope{},
+		},
+	)
+
+	getCredentialsHandler := credentials.NewGetCredentialsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getCredentialsEndpoint,
+		Handler:  getCredentialsHandler,
+		Router:   r,
+	})
+
 	return routes
 	return routes
 }
 }

+ 56 - 0
api/server/router/infra.go

@@ -136,6 +136,62 @@ func getInfraRoutes(
 		Router:   r,
 		Router:   r,
 	})
 	})
 
 
+	// GET /api/projects/{project_id}/infras/{infra_id}/current -> infra.NewInfraGetHandler
+	getCurrentEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/current",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.InfraScope,
+			},
+		},
+	)
+
+	getCurrentHandler := infra.NewInfraGetCurrentHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getCurrentEndpoint,
+		Handler:  getCurrentHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/infras/{infra_id}/desired -> infra.NewInfraGetHandler
+	getDesiredEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/desired",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.InfraScope,
+			},
+		},
+	)
+
+	getDesiredHandler := infra.NewInfraGetDesiredHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getDesiredEndpoint,
+		Handler:  getDesiredHandler,
+		Router:   r,
+	})
+
 	// DELETE /api/projects/{project_id}/infras/{infra_id} -> infra.NewInfraDeleteHandler
 	// DELETE /api/projects/{project_id}/infras/{infra_id} -> infra.NewInfraDeleteHandler
 	deleteEndpoint := factory.NewAPIEndpoint(
 	deleteEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&types.APIRequestMetadata{

+ 56 - 0
api/server/router/project.go

@@ -138,6 +138,62 @@ func getProjectRoutes(
 		Router:   r,
 		Router:   r,
 	})
 	})
 
 
+	// GET /api/projects/{project_id}/onboarding -> project.NewProjectGetOnboardingHandler
+	getOnboardingEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/onboarding",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	getOnboardingHandler := project.NewOnboardingGetHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getOnboardingEndpoint,
+		Handler:  getOnboardingHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/onboarding -> project.NewProjectGetOnboardingHandler
+	updateOnboardingEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/onboarding",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	updateOnboardingHandler := project.NewOnboardingUpdateHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: updateOnboardingEndpoint,
+		Handler:  updateOnboardingHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/usage -> project.NewProjectGetUsageHandler
 	// GET /api/projects/{project_id}/usage -> project.NewProjectGetUsageHandler
 	getUsageEndpoint := factory.NewAPIEndpoint(
 	getUsageEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&types.APIRequestMetadata{

+ 81 - 0
api/server/router/project_integration.go

@@ -79,6 +79,33 @@ func getProjectIntegrationRoutes(
 		Router:   r,
 		Router:   r,
 	})
 	})
 
 
+	// GET /api/projects/{project_id}/integrations/do -> project_integration.NewListDOHandler
+	listDOEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/do",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	listDOHandler := project_integration.NewListDOHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: listDOEndpoint,
+		Handler:  listDOHandler,
+		Router:   r,
+	})
+
 	// POST /api/projects/{project_id}/integrations/basic -> project_integration.NewCreateBasicHandler
 	// POST /api/projects/{project_id}/integrations/basic -> project_integration.NewCreateBasicHandler
 	createBasicEndpoint := factory.NewAPIEndpoint(
 	createBasicEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&types.APIRequestMetadata{
@@ -135,6 +162,33 @@ func getProjectIntegrationRoutes(
 		Router:   r,
 		Router:   r,
 	})
 	})
 
 
+	// GET /api/projects/{project_id}/integrations/aws -> project_integration.NewListAWSHandler
+	listAWSEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/aws",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	listAWSHandler := project_integration.NewListAWSHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: listAWSEndpoint,
+		Handler:  listAWSHandler,
+		Router:   r,
+	})
+
 	// POST /api/projects/{project_id}/integrations/aws/overwrite -> project_integration.NewOverwriteAWSHandler
 	// POST /api/projects/{project_id}/integrations/aws/overwrite -> project_integration.NewOverwriteAWSHandler
 	overwriteAWSEndpoint := factory.NewAPIEndpoint(
 	overwriteAWSEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&types.APIRequestMetadata{
@@ -191,5 +245,32 @@ func getProjectIntegrationRoutes(
 		Router:   r,
 		Router:   r,
 	})
 	})
 
 
+	// GET /api/projects/{project_id}/integrations/gcp -> project_integration.NewListGCPHandler
+	listGCPEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/gcp",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	listGCPHandler := project_integration.NewListGCPHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: listGCPEndpoint,
+		Handler:  listGCPHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 	return routes, newPath
 }
 }

+ 6 - 1
api/server/shared/config/config.go

@@ -15,6 +15,7 @@ import (
 	"github.com/porter-dev/porter/internal/notifier"
 	"github.com/porter-dev/porter/internal/notifier"
 	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/repository"
+	"github.com/porter-dev/porter/internal/repository/credentials"
 	"golang.org/x/oauth2"
 	"golang.org/x/oauth2"
 	"gorm.io/gorm"
 	"gorm.io/gorm"
 )
 )
@@ -90,8 +91,12 @@ type Config struct {
 	// WhitelistedUsers do not count toward usage limits
 	// WhitelistedUsers do not count toward usage limits
 	WhitelistedUsers map[uint]uint
 	WhitelistedUsers map[uint]uint
 
 
-  // PowerDNSClient is a client for PowerDNS, if the Porter instance supports vanity URLs
+	// PowerDNSClient is a client for PowerDNS, if the Porter instance supports vanity URLs
 	PowerDNSClient *powerdns.Client
 	PowerDNSClient *powerdns.Client
+
+	// CredentialBackend is the backend for credential storage, if external cred storage (like Vault)
+	// is used
+	CredentialBackend credentials.CredentialStorage
 }
 }
 
 
 type ConfigLoader interface {
 type ConfigLoader interface {

+ 12 - 3
api/server/shared/config/env/envconfs.go

@@ -53,11 +53,16 @@ type ServerConf struct {
 	IronPlansServerURL string `env:"IRON_PLANS_SERVER_URL"`
 	IronPlansServerURL string `env:"IRON_PLANS_SERVER_URL"`
 	WhitelistedUsers   []uint `env:"WHITELISTED_USERS"`
 	WhitelistedUsers   []uint `env:"WHITELISTED_USERS"`
 
 
-	DOClientID                 string `env:"DO_CLIENT_ID"`
-	DOClientSecret             string `env:"DO_CLIENT_SECRET"`
+	DOClientID     string `env:"DO_CLIENT_ID"`
+	DOClientSecret string `env:"DO_CLIENT_SECRET"`
+
+	// Options for the provisioner jobs
 	ProvisionerImageTag        string `env:"PROV_IMAGE_TAG,default=latest"`
 	ProvisionerImageTag        string `env:"PROV_IMAGE_TAG,default=latest"`
 	ProvisionerImagePullSecret string `env:"PROV_IMAGE_PULL_SECRET"`
 	ProvisionerImagePullSecret string `env:"PROV_IMAGE_PULL_SECRET"`
-	SegmentClientKey           string `env:"SEGMENT_CLIENT_KEY"`
+	ProvisionerJobNamespace    string `env:"PROV_JOB_NAMESPACE,default=default"`
+	ProvisionerBackendURL      string `env:"PROV_BACKEND_URL"`
+
+	SegmentClientKey string `env:"SEGMENT_CLIENT_KEY"`
 
 
 	// PowerDNS client API key and the host of the PowerDNS API server
 	// PowerDNS client API key and the host of the PowerDNS API server
 	PowerDNSAPIServerURL string `env:"POWER_DNS_API_SERVER_URL"`
 	PowerDNSAPIServerURL string `env:"POWER_DNS_API_SERVER_URL"`
@@ -93,6 +98,10 @@ type DBConf struct {
 
 
 	SQLLite     bool   `env:"SQL_LITE,default=false"`
 	SQLLite     bool   `env:"SQL_LITE,default=false"`
 	SQLLitePath string `env:"SQL_LITE_PATH,default=/porter/porter.db"`
 	SQLLitePath string `env:"SQL_LITE_PATH,default=/porter/porter.db"`
+
+	VaultPrefix    string `env:"VAULT_PREFIX,default=production"`
+	VaultAPIKey    string `env:"VAULT_API_KEY"`
+	VaultServerURL string `env:"VAULT_SERVER_URL"`
 }
 }
 
 
 // RedisConf is the redis config required for the provisioner container
 // RedisConf is the redis config required for the provisioner container

+ 9 - 0
api/server/shared/config/loader/init_ee.go

@@ -4,6 +4,7 @@ package loader
 
 
 import (
 import (
 	eeBilling "github.com/porter-dev/porter/ee/billing"
 	eeBilling "github.com/porter-dev/porter/ee/billing"
+	"github.com/porter-dev/porter/ee/integrations/vault"
 	"github.com/porter-dev/porter/ee/models"
 	"github.com/porter-dev/porter/ee/models"
 	eeGorm "github.com/porter-dev/porter/ee/repository/gorm"
 	eeGorm "github.com/porter-dev/porter/ee/repository/gorm"
 	"github.com/porter-dev/porter/internal/billing"
 	"github.com/porter-dev/porter/internal/billing"
@@ -38,4 +39,12 @@ func init() {
 	} else {
 	} else {
 		InstanceBillingManager = &billing.NoopBillingManager{}
 		InstanceBillingManager = &billing.NoopBillingManager{}
 	}
 	}
+
+	if InstanceEnvConf.DBConf.VaultAPIKey != "" && InstanceEnvConf.DBConf.VaultServerURL != "" && InstanceEnvConf.DBConf.VaultPrefix != "" {
+		InstanceCredentialBackend = vault.NewClient(
+			InstanceEnvConf.DBConf.VaultServerURL,
+			InstanceEnvConf.DBConf.VaultAPIKey,
+			InstanceEnvConf.DBConf.VaultPrefix,
+		)
+	}
 }
 }

+ 9 - 6
api/server/shared/config/loader/loader.go

@@ -23,6 +23,7 @@ import (
 	"github.com/porter-dev/porter/internal/notifier"
 	"github.com/porter-dev/porter/internal/notifier"
 	"github.com/porter-dev/porter/internal/notifier/sendgrid"
 	"github.com/porter-dev/porter/internal/notifier/sendgrid"
 	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/oauth"
+	"github.com/porter-dev/porter/internal/repository/credentials"
 	"github.com/porter-dev/porter/internal/repository/gorm"
 	"github.com/porter-dev/porter/internal/repository/gorm"
 
 
 	lr "github.com/porter-dev/porter/internal/logger"
 	lr "github.com/porter-dev/porter/internal/logger"
@@ -33,6 +34,7 @@ import (
 var InstanceBillingManager billing.BillingManager
 var InstanceBillingManager billing.BillingManager
 var InstanceEnvConf *envloader.EnvConf
 var InstanceEnvConf *envloader.EnvConf
 var InstanceDB *pgorm.DB
 var InstanceDB *pgorm.DB
+var InstanceCredentialBackend credentials.CredentialStorage
 
 
 type EnvConfigLoader struct {
 type EnvConfigLoader struct {
 	version string
 	version string
@@ -60,11 +62,12 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 	sc := envConf.ServerConf
 	sc := envConf.ServerConf
 
 
 	res = &config.Config{
 	res = &config.Config{
-		Logger:         lr.NewConsole(sc.Debug),
-		ServerConf:     sc,
-		DBConf:         envConf.DBConf,
-		RedisConf:      envConf.RedisConf,
-		BillingManager: InstanceBillingManager,
+		Logger:            lr.NewConsole(sc.Debug),
+		ServerConf:        sc,
+		DBConf:            envConf.DBConf,
+		RedisConf:         envConf.RedisConf,
+		BillingManager:    InstanceBillingManager,
+		CredentialBackend: InstanceCredentialBackend,
 	}
 	}
 
 
 	res.Metadata = config.MetadataFromConf(envConf.ServerConf, e.version)
 	res.Metadata = config.MetadataFromConf(envConf.ServerConf, e.version)
@@ -82,7 +85,7 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 		key[i] = b
 		key[i] = b
 	}
 	}
 
 
-	res.Repo = gorm.NewRepository(InstanceDB, &key)
+	res.Repo = gorm.NewRepository(InstanceDB, &key, InstanceCredentialBackend)
 
 
 	// create the session store
 	// create the session store
 	res.Store, err = sessionstore.NewStore(
 	res.Store, err = sessionstore.NewStore(

+ 20 - 0
api/types/infra.go

@@ -1,5 +1,7 @@
 package types
 package types
 
 
+import "time"
+
 // InfraStatus is the status that an infrastructure can take
 // InfraStatus is the status that an infrastructure can take
 type InfraStatus string
 type InfraStatus string
 
 
@@ -29,6 +31,9 @@ const (
 type Infra struct {
 type Infra struct {
 	ID uint `json:"id"`
 	ID uint `json:"id"`
 
 
+	CreatedAt time.Time `json:"created_at"`
+	UpdatedAt time.Time `json:"updated_at"`
+
 	// The project that this integration belongs to
 	// The project that this integration belongs to
 	ProjectID uint `json:"project_id"`
 	ProjectID uint `json:"project_id"`
 
 
@@ -37,4 +42,19 @@ type Infra struct {
 
 
 	// Status is the status of the infra
 	// Status is the status of the infra
 	Status InfraStatus `json:"status"`
 	Status InfraStatus `json:"status"`
+
+	// The AWS integration that was used to create the infra
+	AWSIntegrationID uint `json:"aws_integration_id,omitempty"`
+
+	// The GCP integration that was used to create the infra
+	GCPIntegrationID uint `json:"gcp_integration_id,omitempty"`
+
+	// The DO integration that was used to create the infra:
+	// this points to an OAuthIntegrationID
+	DOIntegrationID uint `json:"do_integration_id,omitempty"`
+
+	// The last-applied, non-sensitive input variables to the provisioner. For now,
+	// this is a map[string]string since we marshal into env vars anyway, but
+	// eventually this config will be more complex.
+	LastApplied map[string]string `json:"last_applied"`
 }
 }

+ 32 - 0
api/types/project.go

@@ -69,3 +69,35 @@ type GetBillingTokenResponse struct {
 type GetProjectBillingResponse struct {
 type GetProjectBillingResponse struct {
 	HasBilling bool `json:"has_billing"`
 	HasBilling bool `json:"has_billing"`
 }
 }
+
+type StepEnum string
+
+const (
+	StepConnectSource StepEnum = "connect_source"
+	StepGithub        StepEnum = "github"
+)
+
+type ConnectedSourceType string
+
+const (
+	ConnectedSourceTypeGithub = "github"
+	ConnectedSourceTypeDocker = "docker"
+)
+
+type OnboardingData struct {
+	CurrentStep                    StepEnum            `json:"current_step"`
+	ConnectedSource                ConnectedSourceType `json:"connected_source"`
+	SkipRegistryConnection         bool                `json:"skip_registry_connection"`
+	SkipResourceProvision          bool                `json:"skip_resource_provision"`
+	RegistryConnectionID           uint                `json:"registry_connection_id"`
+	RegistryConnectionCredentialID uint                `json:"registry_connection_credential_id"`
+	RegistryConnectionProvider     string              `json:"registry_connection_provider"`
+	RegistryInfraID                uint                `json:"registry_infra_id"`
+	RegistryInfraCredentialID      uint                `json:"registry_infra_credential_id"`
+	RegistryInfraProvider          string              `json:"registry_infra_provider"`
+	ClusterInfraID                 uint                `json:"cluster_infra_id"`
+	ClusterInfraCredentialID       uint                `json:"cluster_infra_credential_id"`
+	ClusterInfraProvider           string              `json:"cluster_infra_provider"`
+}
+
+type UpdateOnboardingRequest OnboardingData

+ 24 - 4
api/types/project_integration.go

@@ -1,5 +1,7 @@
 package types
 package types
 
 
+import "time"
+
 // The supported oauth mechanism clients
 // The supported oauth mechanism clients
 const (
 const (
 	OAuthGithub       OAuthIntegrationClient = "github"
 	OAuthGithub       OAuthIntegrationClient = "github"
@@ -12,6 +14,8 @@ type OAuthIntegrationClient string
 
 
 // OAuthIntegration is an OAuthIntegration to be shared over REST
 // OAuthIntegration is an OAuthIntegration to be shared over REST
 type OAuthIntegration struct {
 type OAuthIntegration struct {
+	CreatedAt time.Time `json:"created_at"`
+
 	ID uint `json:"id"`
 	ID uint `json:"id"`
 
 
 	// The name of the auth mechanism
 	// The name of the auth mechanism
@@ -22,6 +26,14 @@ type OAuthIntegration struct {
 
 
 	// The project that this integration belongs to
 	// The project that this integration belongs to
 	ProjectID uint `json:"project_id"`
 	ProjectID uint `json:"project_id"`
+
+	// (optional) an identifying email on the target identity provider.
+	// for example, for DigitalOcean this is the user's email.
+	TargetEmail string `json:"target_email,omitempty"`
+
+	// (optional) an identifying string on the target identity provider.
+	// for example, for DigitalOcean this is the target project name.
+	TargetName string `json:"target_id,omitempty"`
 }
 }
 
 
 type ListOAuthResponse []*OAuthIntegration
 type ListOAuthResponse []*OAuthIntegration
@@ -46,6 +58,8 @@ type CreateBasicResponse struct {
 }
 }
 
 
 type AWSIntegration struct {
 type AWSIntegration struct {
+	CreatedAt time.Time `json:"created_at"`
+
 	ID uint `json:"id"`
 	ID uint `json:"id"`
 
 
 	// The id of the user that linked this auth mechanism
 	// The id of the user that linked this auth mechanism
@@ -58,6 +72,8 @@ type AWSIntegration struct {
 	AWSArn string `json:"aws_arn"`
 	AWSArn string `json:"aws_arn"`
 }
 }
 
 
+type ListAWSResponse []*AWSIntegration
+
 type CreateAWSRequest struct {
 type CreateAWSRequest struct {
 	AWSRegion          string `json:"aws_region"`
 	AWSRegion          string `json:"aws_region"`
 	AWSClusterID       string `json:"aws_cluster_id"`
 	AWSClusterID       string `json:"aws_cluster_id"`
@@ -81,6 +97,8 @@ type OverwriteAWSResponse struct {
 }
 }
 
 
 type GCPIntegration struct {
 type GCPIntegration struct {
+	CreatedAt time.Time `json:"created_at"`
+
 	ID uint `json:"id"`
 	ID uint `json:"id"`
 
 
 	// The id of the user that linked this auth mechanism
 	// The id of the user that linked this auth mechanism
@@ -89,13 +107,15 @@ type GCPIntegration struct {
 	// The project that this integration belongs to
 	// The project that this integration belongs to
 	ProjectID uint `json:"project_id"`
 	ProjectID uint `json:"project_id"`
 
 
-	// The GCP project id where the service account for this auth mechanism persists
-	GCPProjectID string `json:"gcp-project-id"`
+	// The GCP service account email for this credential
+	GCPSAEmail string `json:"gcp_sa_email"`
 
 
-	// The GCP user email that linked this service account
-	GCPUserEmail string `json:"gcp-user-email"`
+	// The GCP project id where the service account for this auth mechanism persists
+	GCPProjectID string `json:"gcp_project_id"`
 }
 }
 
 
+type ListGCPResponse []*GCPIntegration
+
 type CreateGCPRequest struct {
 type CreateGCPRequest struct {
 	GCPKeyData   string `json:"gcp_key_data" form:"required"`
 	GCPKeyData   string `json:"gcp_key_data" form:"required"`
 	GCPProjectID string `json:"gcp_project_id"`
 	GCPProjectID string `json:"gcp_project_id"`

+ 4 - 0
api/types/provision.go

@@ -9,6 +9,7 @@ type CreateECRInfraRequest struct {
 type CreateEKSInfraRequest struct {
 type CreateEKSInfraRequest struct {
 	EKSName          string `json:"eks_name" form:"required"`
 	EKSName          string `json:"eks_name" form:"required"`
 	MachineType      string `json:"machine_type"`
 	MachineType      string `json:"machine_type"`
+	IssuerEmail      string `json:"issuer_email" form:"required"`
 	ProjectID        uint   `json:"-" form:"required"`
 	ProjectID        uint   `json:"-" form:"required"`
 	AWSIntegrationID uint   `json:"aws_integration_id" form:"required"`
 	AWSIntegrationID uint   `json:"aws_integration_id" form:"required"`
 }
 }
@@ -20,6 +21,8 @@ type CreateGCRInfraRequest struct {
 
 
 type CreateGKEInfraRequest struct {
 type CreateGKEInfraRequest struct {
 	GKEName          string `json:"gke_name" form:"required"`
 	GKEName          string `json:"gke_name" form:"required"`
+	GCPRegion        string `json:"gcp_region" form:"required"`
+	IssuerEmail      string `json:"issuer_email" form:"required"`
 	ProjectID        uint   `json:"-" form:"required"`
 	ProjectID        uint   `json:"-" form:"required"`
 	GCPIntegrationID uint   `json:"gcp_integration_id" form:"required"`
 	GCPIntegrationID uint   `json:"gcp_integration_id" form:"required"`
 }
 }
@@ -33,6 +36,7 @@ type CreateDOCRInfraRequest struct {
 
 
 type CreateDOKSInfraRequest struct {
 type CreateDOKSInfraRequest struct {
 	DORegion        string `json:"do_region" form:"required"`
 	DORegion        string `json:"do_region" form:"required"`
+	IssuerEmail     string `json:"issuer_email" form:"required"`
 	DOKSName        string `json:"doks_name" form:"required"`
 	DOKSName        string `json:"doks_name" form:"required"`
 	ProjectID       uint   `json:"-" form:"required"`
 	ProjectID       uint   `json:"-" form:"required"`
 	DOIntegrationID uint   `json:"do_integration_id" form:"required"`
 	DOIntegrationID uint   `json:"do_integration_id" form:"required"`

+ 13 - 0
api/types/registry.go

@@ -23,6 +23,19 @@ type Registry struct {
 
 
 	// The infra id, if registry was provisioned with Porter
 	// The infra id, if registry was provisioned with Porter
 	InfraID uint `json:"infra_id"`
 	InfraID uint `json:"infra_id"`
+
+	// The AWS integration that was used to create or connect the registry
+	AWSIntegrationID uint `json:"aws_integration_id,omitempty"`
+
+	// The GCP integration that was used to create or connect the registry
+	GCPIntegrationID uint `json:"gcp_integration_id,omitempty"`
+
+	// The DO integration that was used to create or connect the registry:
+	// this points to an OAuthIntegrationID
+	DOIntegrationID uint `json:"do_integration_id,omitempty"`
+
+	// The basic integration that was used to connect the registry:
+	BasicIntegrationID uint `json:"basic_integration_id,omitempty"`
 }
 }
 
 
 // Repository is a collection of images
 // Repository is a collection of images

+ 2 - 1
cmd/migrate/keyrotate/helpers_test.go

@@ -67,6 +67,7 @@ func setupTestEnv(tester *tester, t *testing.T) {
 		&models.ClusterResolver{},
 		&models.ClusterResolver{},
 		&models.Infra{},
 		&models.Infra{},
 		&models.GitActionConfig{},
 		&models.GitActionConfig{},
+		&models.Onboarding{},
 		&ints.KubeIntegration{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},
 		&ints.OIDCIntegration{},
@@ -92,7 +93,7 @@ func setupTestEnv(tester *tester, t *testing.T) {
 	tester.Key = &key
 	tester.Key = &key
 	tester.DB = db
 	tester.DB = db
 
 
-	tester.repo = gorm.NewRepository(db, &key)
+	tester.repo = gorm.NewRepository(db, &key, nil)
 }
 }
 
 
 func cleanup(tester *tester, t *testing.T) {
 func cleanup(tester *tester, t *testing.T) {

+ 3 - 3
cmd/migrate/keyrotate/rotate.go

@@ -574,7 +574,7 @@ func rotateOAuthIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 	}
 	}
 
 
 	// cluster-scoped repository
 	// cluster-scoped repository
-	repo := gorm.NewOAuthIntegrationRepository(db, oldKey).(*gorm.OAuthIntegrationRepository)
+	repo := gorm.NewOAuthIntegrationRepository(db, oldKey, nil).(*gorm.OAuthIntegrationRepository)
 
 
 	// iterate (count / stepSize) + 1 times using Limit and Offset
 	// iterate (count / stepSize) + 1 times using Limit and Offset
 	for i := 0; i < (int(count)/stepSize)+1; i++ {
 	for i := 0; i < (int(count)/stepSize)+1; i++ {
@@ -629,7 +629,7 @@ func rotateGCPIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 	}
 	}
 
 
 	// cluster-scoped repository
 	// cluster-scoped repository
-	repo := gorm.NewGCPIntegrationRepository(db, oldKey).(*gorm.GCPIntegrationRepository)
+	repo := gorm.NewGCPIntegrationRepository(db, oldKey, nil).(*gorm.GCPIntegrationRepository)
 
 
 	// iterate (count / stepSize) + 1 times using Limit and Offset
 	// iterate (count / stepSize) + 1 times using Limit and Offset
 	for i := 0; i < (int(count)/stepSize)+1; i++ {
 	for i := 0; i < (int(count)/stepSize)+1; i++ {
@@ -682,7 +682,7 @@ func rotateAWSIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 	}
 	}
 
 
 	// cluster-scoped repository
 	// cluster-scoped repository
-	repo := gorm.NewAWSIntegrationRepository(db, oldKey).(*gorm.AWSIntegrationRepository)
+	repo := gorm.NewAWSIntegrationRepository(db, oldKey, nil).(*gorm.AWSIntegrationRepository)
 
 
 	// iterate (count / stepSize) + 1 times using Limit and Offset
 	// iterate (count / stepSize) + 1 times using Limit and Offset
 	for i := 0; i < (int(count)/stepSize)+1; i++ {
 	for i := 0; i < (int(count)/stepSize)+1; i++ {

+ 3 - 3
cmd/migrate/keyrotate/rotate_test.go

@@ -471,7 +471,7 @@ func TestOAuthIntegrationModelRotation(t *testing.T) {
 	}
 	}
 
 
 	// very all oauths decoded properly
 	// very all oauths decoded properly
-	repo := gorm.NewOAuthIntegrationRepository(tester.DB, &newKey).(*gorm.OAuthIntegrationRepository)
+	repo := gorm.NewOAuthIntegrationRepository(tester.DB, &newKey, nil).(*gorm.OAuthIntegrationRepository)
 
 
 	oauths := []*ints.OAuthIntegration{}
 	oauths := []*ints.OAuthIntegration{}
 
 
@@ -527,7 +527,7 @@ func TestGCPIntegrationModelRotation(t *testing.T) {
 	}
 	}
 
 
 	// very all gcps decoded properly
 	// very all gcps decoded properly
-	repo := gorm.NewGCPIntegrationRepository(tester.DB, &newKey).(*gorm.GCPIntegrationRepository)
+	repo := gorm.NewGCPIntegrationRepository(tester.DB, &newKey, nil).(*gorm.GCPIntegrationRepository)
 
 
 	gcps := []*ints.GCPIntegration{}
 	gcps := []*ints.GCPIntegration{}
 
 
@@ -575,7 +575,7 @@ func TestAWSIntegrationModelRotation(t *testing.T) {
 	}
 	}
 
 
 	// very all awss decoded properly
 	// very all awss decoded properly
-	repo := gorm.NewAWSIntegrationRepository(tester.DB, &newKey).(*gorm.AWSIntegrationRepository)
+	repo := gorm.NewAWSIntegrationRepository(tester.DB, &newKey, nil).(*gorm.AWSIntegrationRepository)
 
 
 	awss := []*ints.AWSIntegration{}
 	awss := []*ints.AWSIntegration{}
 
 

+ 4 - 0
cmd/migrate/main.go

@@ -60,6 +60,10 @@ func main() {
 			logger.Fatal().Err(err).Msg("key rotation failed")
 			logger.Fatal().Err(err).Msg("key rotation failed")
 		}
 		}
 	}
 	}
+
+	if err := InstanceMigrate(db, envConf.DBConf); err != nil {
+		logger.Fatal().Err(err).Msg("vault migration failed")
+	}
 }
 }
 
 
 type RotateConf struct {
 type RotateConf struct {

+ 12 - 0
cmd/migrate/migrate_ce.go

@@ -0,0 +1,12 @@
+// +build !ee
+
+package main
+
+import (
+	"github.com/porter-dev/porter/api/server/shared/config/env"
+	"gorm.io/gorm"
+)
+
+func InstanceMigrate(db *gorm.DB, dbConf *env.DBConf) error {
+	return nil
+}

+ 40 - 0
cmd/migrate/migrate_ee.go

@@ -0,0 +1,40 @@
+// +build ee
+
+package main
+
+import (
+	"log"
+
+	"github.com/joeshaw/envdecode"
+	"github.com/porter-dev/porter/api/server/shared/config/env"
+	"github.com/porter-dev/porter/ee/migrate"
+	"gorm.io/gorm"
+)
+
+func InstanceMigrate(db *gorm.DB, dbConf *env.DBConf) error {
+	if shouldRotateInit, shouldRotateFinalize := shouldVaultRotate(); shouldRotateInit {
+		if err := migrate.MigrateVault(db, dbConf, shouldRotateFinalize); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+type VaultMigrateConf struct {
+	// we add a dummy field to avoid empty struct issue with envdecode
+	DummyField           string `env:"ASDF,default=asdf"`
+	VaultMigrateInit     bool   `env:"VAULT_MIGRATE_INIT"`
+	VaultMigrateFinalize bool   `env:"VAULT_MIGRATE_FINALIZE"`
+}
+
+func shouldVaultRotate() (bool, bool) {
+	var c VaultMigrateConf
+
+	if err := envdecode.StrictDecode(&c); err != nil {
+		log.Fatalf("Failed to decode Vault migration conf: %s", err)
+		return false, false
+	}
+
+	return c.VaultMigrateInit, c.VaultMigrateFinalize
+}

+ 2 - 1
dashboard/.dockerignore

@@ -1 +1,2 @@
-node_modules
+node_modules
+.env

+ 13 - 0
dashboard/package-lock.json

@@ -9246,6 +9246,11 @@
         "ipaddr.js": "1.9.1"
         "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": {
     "prr": {
       "version": "1.0.1",
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
       "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
@@ -11527,6 +11532,14 @@
       "integrity": "sha512-gTpR5XQNKFwOd4clxfnhaqvfqMpqEwr4tOtCyz4MtYZX2JYhfr1JvBFKdS+7K/9rfpZR3VLX+YWBbKoxCgS43Q==",
       "integrity": "sha512-gTpR5XQNKFwOd4clxfnhaqvfqMpqEwr4tOtCyz4MtYZX2JYhfr1JvBFKdS+7K/9rfpZR3VLX+YWBbKoxCgS43Q==",
       "dev": true
       "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": {
     "value-equal": {
       "version": "1.0.1",
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz",
       "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz",

+ 2 - 1
dashboard/package.json

@@ -46,7 +46,8 @@
     "regenerator-runtime": "^0.13.9",
     "regenerator-runtime": "^0.13.9",
     "semver": "^7.3.5",
     "semver": "^7.3.5",
     "stacktrace-js": "^2.0.2",
     "stacktrace-js": "^2.0.2",
-    "styled-components": "^5.2.0"
+    "styled-components": "^5.2.0",
+    "valtio": "^1.2.4"
   },
   },
   "scripts": {
   "scripts": {
     "test": "echo \"Error: no test specified\" && exit 1",
     "test": "echo \"Error: no test specified\" && exit 1",

+ 13 - 11
dashboard/src/components/Boilerplate.tsx

@@ -1,16 +1,18 @@
-import React, { Component } from "react";
-import styled from "styled-components";
+import React, { useState } from "react";
 
 
-type PropsType = {};
+import styled from "styled-components";
 
 
-type StateType = {};
+type Props = {
+};
 
 
-export default class Boilerplate extends Component<PropsType, StateType> {
-  state = {};
+export const Boilerplate: React.FC<Props> = (props) => {
+  const [someState, setSomeState] = useState("");
 
 
-  render() {
-    return <StyledBoilerplate>boilerplate</StyledBoilerplate>;
-  }
-}
+  return (
+    <StyledBoilerplate>
+    </StyledBoilerplate>
+  );
+};
 
 
-const StyledBoilerplate = styled.div``;
+const StyledBoilerplate = styled.div`
+`;

+ 42 - 0
dashboard/src/components/Breadcrumb.tsx

@@ -0,0 +1,42 @@
+import { Steps } from "main/home/onboarding/types";
+import React, { Fragment, useState } from "react";
+
+import styled from "styled-components";
+
+type Props = {
+  currentStep: string;
+  steps: { value: string; label: string }[];
+  onClickStep?: (step: string) => void;
+};
+
+const Breadcrumb: React.FC<Props> = ({ currentStep, steps, onClickStep }) => {
+  return (
+    <StyledBreadcrumb>
+      {steps.map((step: { value: string; label: string }, i: number) => {
+        return (
+          <Fragment key={i}>
+            <Crumb
+              bold={currentStep === step.value}
+              onClick={() => onClickStep && onClickStep(step.value)}
+            >
+              {step.label}
+            </Crumb>
+            {i !== steps.length - 1 && " > "}
+          </Fragment>
+        );
+      })}
+    </StyledBreadcrumb>
+  );
+};
+
+export default Breadcrumb;
+
+const StyledBreadcrumb = styled.div`
+  color: #aaaabb;
+`;
+
+const Crumb = styled.span<{ bold: boolean }>`
+  font-weight: ${(props) => (props.bold ? "600" : "normal")};
+  color: ${(props) => (props.bold ? "#ffffff" : "#aaaabb")};
+  font-size: 13px;
+`;

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

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

Разница между файлами не показана из-за своего большого размера
+ 28 - 0
dashboard/src/components/PageIllustration.tsx


+ 271 - 0
dashboard/src/components/ProvisionerStatus.tsx

@@ -0,0 +1,271 @@
+import { Steps } from "main/home/onboarding/types";
+import React, { useState } from "react";
+import { integrationList } from "shared/common";
+
+import loading from "assets/loading.gif";
+
+import styled from "styled-components";
+
+type Props = {
+  modules: TFModule[];
+};
+
+export interface TFModule {
+  id: number;
+  kind: string;
+  status: string;
+  created_at: string;
+  global_errors?: TFResourceError[];
+  got_desired: boolean;
+  // optional resources, if not created
+  resources?: TFResource[];
+}
+
+export interface TFResourceError {
+  errored_out: boolean;
+  error_context?: string;
+}
+
+export interface TFResource {
+  addr: string;
+  provisioned: boolean;
+  errored: TFResourceError;
+}
+
+const nameMap: { [key: string]: string } = {
+  eks: "Elastic Kubernetes Service (EKS)",
+  ecr: "Elastic Container Registry (ECR)",
+  doks: "DigitalOcean Kubernetes Service (DOKS)",
+  docr: "DigitalOcean Container Registry (DOCR)",
+  gke: "Google Kubernetes Engine (GKE)",
+  gcr: "Google Container Registry (GCR)",
+};
+
+const ProvisionerStatus: React.FC<Props> = ({ modules }) => {
+  const renderStatus = (status: string) => {
+    if (status === "successful") {
+      return (
+        <StatusIcon successful={true}>
+          <i className="material-icons">done</i>
+        </StatusIcon>
+      );
+    } else if (status === "loading") {
+      return (
+        <StatusIcon>
+          <LoadingGif src={loading} />
+        </StatusIcon>
+      );
+    } else if (status === "error") {
+      return (
+        <StatusIcon>
+          <i className="material-icons">error_outline</i>
+        </StatusIcon>
+      );
+    }
+  };
+
+  const readableDate = (s: string) => {
+    const ts = new Date(s);
+    const date = ts.toLocaleDateString();
+    const time = ts.toLocaleTimeString([], {
+      hour: "numeric",
+      minute: "2-digit",
+    });
+    return `${time} on ${date}`;
+  };
+
+  const renderModules = () => {
+    return modules.map((val) => {
+      const totalResources = val.resources?.length;
+      const provisionedResources = val.resources?.filter((resource) => {
+        return resource.provisioned;
+      }).length;
+
+      let errors: string[] = [];
+
+      if (val.status == "destroyed") {
+        errors.push("Note: this infrastructure was automatically destroyed.");
+      }
+
+      let hasError =
+        val.resources?.filter((resource) => {
+          if (resource.errored?.errored_out) {
+            errors.push(resource.errored?.error_context);
+          }
+
+          return resource.errored?.errored_out;
+        }).length > 0;
+
+      if (val.global_errors) {
+        for (let globalErr of val.global_errors) {
+          errors.push("Global error: " + globalErr.error_context);
+          hasError = true;
+        }
+      }
+
+      const width =
+        val.status == "created"
+          ? 100
+          : 100 * (provisionedResources / (totalResources * 1.0)) || 0;
+
+      let error = null;
+
+      if (hasError) {
+        error = errors.map((error, index) => {
+          return <ExpandedError key={index}>{error}</ExpandedError>;
+        });
+      }
+      let loadingFill;
+      let status;
+
+      if (hasError || val.status == "destroyed") {
+        loadingFill = <LoadingFill status="error" width={width + "%"} />;
+        status = renderStatus("error");
+      } else if (width == 100) {
+        loadingFill = <LoadingFill status="successful" width={width + "%"} />;
+        status = renderStatus("successful");
+      } else {
+        loadingFill = <LoadingFill status="loading" width={width + "%"} />;
+        status = renderStatus("loading");
+      }
+
+      return (
+        <InfraObject key={val.id}>
+          <InfraHeader>
+            <Flex>
+              {status}
+              {integrationList[val.kind] && (
+                <Icon src={integrationList[val.kind].icon} />
+              )}
+              {nameMap[val.kind]}
+            </Flex>
+            <Timestamp>Started {readableDate(val.created_at)}</Timestamp>
+          </InfraHeader>
+          <LoadingBar>{loadingFill}</LoadingBar>
+          <ErrorWrapper>{error}</ErrorWrapper>
+        </InfraObject>
+      );
+    });
+  };
+
+  return <StyledProvisionerStatus>{renderModules()}</StyledProvisionerStatus>;
+};
+
+export default ProvisionerStatus;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Timestamp = styled.div`
+  font-size: 13px;
+  font-weight: 400;
+  color: #ffffff55;
+`;
+
+const Icon = styled.img`
+  height: 20px;
+  margin-right: 10px;
+`;
+
+const ErrorWrapper = styled.div`
+  max-height: 150px;
+  margin-top: 20px;
+  overflow-y: auto;
+  user-select: text;
+  padding: 0 15px;
+`;
+
+const ExpandedError = styled.div`
+  background: #ffffff22;
+  border-radius: 5px;
+  padding: 15px;
+  font-size: 13px;
+  font-family: monospace;
+  border: 1px solid #aaaabb;
+  margin-bottom: 17px;
+  padding-bottom: 17px;
+`;
+
+const LoadingFill = styled.div<{ width: string; status: string }>`
+  width: ${(props) => props.width};
+  background: ${(props) =>
+    props.status === "successful"
+      ? "rgb(56, 168, 138)"
+      : props.status === "error"
+      ? "#fcba03"
+      : "linear-gradient(to right, #8ce1ff, #616FEE)"};
+  height: 100%;
+  background-size: 250% 100%;
+  animation: moving-gradient 2s infinite;
+  animation-timing-function: ease-in-out;
+  animation-direction: alternate;
+
+  @keyframes moving-gradient {
+    0% {
+        background-position: left bottom;
+    }
+
+    100% {
+        background-position: right bottom;
+    }
+  }​
+`;
+
+const StatusIcon = styled.div<{ successful?: boolean }>`
+  display: flex;
+  align-items: center;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #ffffff55;
+  max-width: 500px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+
+  > i {
+    font-size: 18px;
+    margin-right: 10px;
+    float: left;
+    color: ${(props) => (props.successful ? "rgb(56, 168, 138)" : "#fcba03")};
+  }
+`;
+
+const LoadingGif = styled.img`
+  width: 15px;
+  height: 15px;
+  margin-right: 9px;
+  margin-bottom: 0px;
+`;
+
+const StyledProvisionerStatus = styled.div`
+  margin-top: 25px;
+`;
+
+const LoadingBar = styled.div`
+  width: calc(100% - 30px);
+  background: #ffffff22;
+  border: 100px;
+  margin: 15px 15px 0;
+  height: 18px;
+  overflow: hidden;
+  border-radius: 100px;
+`;
+
+const InfraObject = styled.div`
+  background: #ffffff22;
+  padding: 15px 0 0;
+  border: 1px solid #aaaabb;
+  border-radius: 5px;
+  margin-bottom: 10px;
+  position: relative;
+`;
+
+const InfraHeader = styled.div`
+  font-size: 13px;
+  font-weight: 500;
+  justify-content: space-between;
+  padding: 0 15px;
+  display: flex;
+  align-items: center;
+`;

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

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

+ 103 - 31
dashboard/src/components/Selector.tsx

@@ -5,7 +5,7 @@ import { Context } from "shared/Context";
 type PropsType = {
 type PropsType = {
   activeValue: string;
   activeValue: string;
   refreshOptions?: () => void;
   refreshOptions?: () => void;
-  options: { value: string; label: string }[];
+  options: { value: string; label: string; icon?: any }[];
   addButton?: boolean;
   addButton?: boolean;
   setActiveValue: (x: string) => void;
   setActiveValue: (x: string) => void;
   width: string;
   width: string;
@@ -14,6 +14,8 @@ type PropsType = {
   dropdownWidth?: string;
   dropdownWidth?: string;
   dropdownMaxHeight?: string;
   dropdownMaxHeight?: string;
   closeOverlay?: boolean;
   closeOverlay?: boolean;
+  placeholder?: string;
+  scrollBuffer?: boolean;
 };
 };
 
 
 type StateType = {};
 type StateType = {};
@@ -58,14 +60,20 @@ export default class Selector extends Component<PropsType, StateType> {
   renderOptionList = () => {
   renderOptionList = () => {
     let { options, activeValue } = this.props;
     let { options, activeValue } = this.props;
     return options.map(
     return options.map(
-      (option: { value: string; label: string }, i: number) => {
+      (option: { value: string; label: string; icon?: any }, i: number) => {
         return (
         return (
           <Option
           <Option
             key={i}
             key={i}
+            height={this.props.height}
             selected={option.value === activeValue}
             selected={option.value === activeValue}
             onClick={() => this.handleOptionClick(option)}
             onClick={() => this.handleOptionClick(option)}
             lastItem={i === options.length - 1}
             lastItem={i === options.length - 1}
           >
           >
+            {option.icon && (
+              <Icon>
+                <img src={option.icon} />
+              </Icon>
+            )}
             {option.label}
             {option.label}
           </Option>
           </Option>
         );
         );
@@ -97,20 +105,23 @@ export default class Selector extends Component<PropsType, StateType> {
   renderDropdown = () => {
   renderDropdown = () => {
     if (this.state.expanded) {
     if (this.state.expanded) {
       return (
       return (
-        <Dropdown
-          ref={this.wrapperRef}
-          dropdownWidth={
-            this.props.dropdownWidth
-              ? this.props.dropdownWidth
-              : this.props.width
-          }
-          dropdownMaxHeight={this.props.dropdownMaxHeight}
-          onClick={() => this.setState({ expanded: false })}
-        >
-          {this.renderDropdownLabel()}
-          {this.renderOptionList()}
-          {this.renderAddButton()}
-        </Dropdown>
+        <DropdownWrapper>
+          <Dropdown
+            ref={this.wrapperRef}
+            dropdownWidth={
+              this.props.dropdownWidth
+                ? this.props.dropdownWidth
+                : this.props.width
+            }
+            dropdownMaxHeight={this.props.dropdownMaxHeight}
+            onClick={() => this.setState({ expanded: false })}
+          >
+            {this.renderDropdownLabel()}
+            {this.renderOptionList()}
+            {this.renderAddButton()}
+          </Dropdown>
+          {this.props.scrollBuffer && <ScrollBuffer />}
+        </DropdownWrapper>
       );
       );
     }
     }
   };
   };
@@ -124,6 +135,24 @@ export default class Selector extends Component<PropsType, StateType> {
     }
     }
   };
   };
 
 
+  renderIcon = () => {
+    var icon;
+    this.props.options.forEach((option: any) => {
+      if (option.icon && option.value === this.props.activeValue) {
+        icon = option.icon;
+      }
+    });
+    return (
+      <>
+        {icon && (
+          <Icon>
+            <img src={icon} />
+          </Icon>
+        )}
+      </>
+    );
+  };
+
   render() {
   render() {
     let { activeValue } = this.props;
     let { activeValue } = this.props;
 
 
@@ -141,9 +170,18 @@ export default class Selector extends Component<PropsType, StateType> {
           width={this.props.width}
           width={this.props.width}
           height={this.props.height}
           height={this.props.height}
         >
         >
-          <TextWrap>
-            {activeValue === "" ? "All" : this.getLabel(activeValue)}
-          </TextWrap>
+          <Flex>
+            {this.renderIcon()}
+            <TextWrap>
+              {
+                activeValue ? (
+                  activeValue === "" ? "All" : this.getLabel(activeValue)
+                ) : (
+                  this.props.placeholder
+                )
+              }
+            </TextWrap>
+          </Flex>
           <i className="material-icons">arrow_drop_down</i>
           <i className="material-icons">arrow_drop_down</i>
         </MainSelector>
         </MainSelector>
         {this.renderDropdown()}
         {this.renderDropdown()}
@@ -154,6 +192,40 @@ export default class Selector extends Component<PropsType, StateType> {
 
 
 Selector.contextType = Context;
 Selector.contextType = Context;
 
 
+const DropdownWrapper = styled.div`
+  position: absolute;
+  width: 100%;
+  right: 0;
+  z-index: 1;
+  top: calc(100% + 5px);
+`;
+
+const ScrollBuffer = styled.div`
+  width: 100%;
+  height: 50px;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Icon = styled.div`
+  height: 20px;
+  width: 30px;
+  margin-left: -5px;
+  margin-right: 10px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  overflow: visible;
+
+  > img {
+    height: 18px;
+    width: auto;
+  }
+`;
+
 const Plus = styled.div`
 const Plus = styled.div`
   margin-right: 10px;
   margin-right: 10px;
   font-size: 15px;
   font-size: 15px;
@@ -193,15 +265,19 @@ const NewOption = styled.div`
   }
   }
 `;
 `;
 
 
-const Option = styled.div`
+const Option = styled.div<{
+  selected: boolean;
+  lastItem: boolean;
+  height: string;
+}>`
   width: 100%;
   width: 100%;
   border-top: 1px solid #00000000;
   border-top: 1px solid #00000000;
   border-bottom: 1px solid
   border-bottom: 1px solid
-    ${(props: { selected: boolean; lastItem: boolean }) =>
-      props.lastItem ? "#ffffff00" : "#ffffff15"};
-  height: 37px;
+    ${(props) => (props.lastItem ? "#ffffff00" : "#ffffff15")};
+  height: ${(props) => props.height || "37px"};
   font-size: 13px;
   font-size: 13px;
-  padding-top: 9px;
+  align-items: center;
+  display: flex;
   align-items: center;
   align-items: center;
   padding-left: 15px;
   padding-left: 15px;
   cursor: pointer;
   cursor: pointer;
@@ -209,8 +285,7 @@ const Option = styled.div`
   white-space: nowrap;
   white-space: nowrap;
   overflow: hidden;
   overflow: hidden;
   text-overflow: ellipsis;
   text-overflow: ellipsis;
-  background: ${(props: { selected: boolean; lastItem: boolean }) =>
-    props.selected ? "#ffffff11" : ""};
+  background: ${(props) => (props.selected ? "#ffffff11" : "")};
 
 
   :hover {
   :hover {
     background: #ffffff22;
     background: #ffffff22;
@@ -227,9 +302,6 @@ const CloseOverlay = styled.div`
 `;
 `;
 
 
 const Dropdown = styled.div`
 const Dropdown = styled.div`
-  position: absolute;
-  right: 0;
-  top: calc(100% + 5px);
   background: #26282f;
   background: #26282f;
   width: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
   width: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
     props.dropdownWidth};
     props.dropdownWidth};
@@ -255,11 +327,11 @@ const MainSelector = styled.div`
   border: 1px solid #ffffff55;
   border: 1px solid #ffffff55;
   font-size: 13px;
   font-size: 13px;
   padding: 5px 10px;
   padding: 5px 10px;
-  padding-left: 12px;
+  padding-left: 15px;
   border-radius: 3px;
   border-radius: 3px;
   display: flex;
   display: flex;
-  align-items: center;
   justify-content: space-between;
   justify-content: space-between;
+  align-items: center;
   cursor: pointer;
   cursor: pointer;
   background: ${(props: {
   background: ${(props: {
     expanded: boolean;
     expanded: boolean;

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

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

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

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

+ 3 - 0
dashboard/src/components/form-components/SelectRow.tsx

@@ -11,6 +11,7 @@ type PropsType = {
   dropdownLabel?: string;
   dropdownLabel?: string;
   width?: string;
   width?: string;
   dropdownMaxHeight?: string;
   dropdownMaxHeight?: string;
+  scrollBuffer?: boolean;
 };
 };
 
 
 type StateType = {};
 type StateType = {};
@@ -22,6 +23,7 @@ export default class SelectRow extends Component<PropsType, StateType> {
         <Label>{this.props.label}</Label>
         <Label>{this.props.label}</Label>
         <SelectWrapper>
         <SelectWrapper>
           <Selector
           <Selector
+            scrollBuffer={this.props.scrollBuffer}
             activeValue={this.props.value}
             activeValue={this.props.value}
             setActiveValue={this.props.setActiveValue}
             setActiveValue={this.props.setActiveValue}
             options={this.props.options}
             options={this.props.options}
@@ -41,6 +43,7 @@ const SelectWrapper = styled.div``;
 const Label = styled.div`
 const Label = styled.div`
   color: #ffffff;
   color: #ffffff;
   margin-bottom: 10px;
   margin-bottom: 10px;
+  font-size: 13px;
 `;
 `;
 
 
 const StyledSelectRow = styled.div`
 const StyledSelectRow = styled.div`

+ 3 - 2
dashboard/src/components/form-components/UploadArea.tsx

@@ -89,6 +89,7 @@ const Message = styled.div`
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
   vertical-align: middle;
   vertical-align: middle;
+  font-size: 13px;
 `;
 `;
 
 
 const Required = styled.div`
 const Required = styled.div`
@@ -112,7 +113,7 @@ const DNDArea = styled.div`
   padding: 5px 10px;
   padding: 5px 10px;
   margin-right: 8px;
   margin-right: 8px;
   width: 100%;
   width: 100%;
-  height: 150px;
+  height: 80px;
   cursor: pointer;
   cursor: pointer;
 `;
 `;
 
 
@@ -126,5 +127,5 @@ const Label = styled.div`
 `;
 `;
 
 
 const StyledUploadArea = styled.div`
 const StyledUploadArea = styled.div`
-  margin-top: 20px;
+  margin-bottom: 20px;
 `;
 `;

+ 4 - 7
dashboard/src/main/Main.tsx

@@ -45,13 +45,10 @@ export default class Main extends Component<PropsType, StateType> {
       .checkAuth("", {}, {})
       .checkAuth("", {}, {})
       .then((res) => {
       .then((res) => {
         if (res && res?.data) {
         if (res && res?.data) {
-          Cohere.identify(
-            res?.data?.id, 
-            {
-              displayName: res?.data?.email,
-              email: res?.data?.email, 
-            }
-          );
+          Cohere.identify(res?.data?.id, {
+            displayName: res?.data?.email,
+            email: res?.data?.email,
+          });
           setUser(res?.data?.id, res?.data?.email);
           setUser(res?.data?.id, res?.data?.email);
           this.setState({
           this.setState({
             isLoggedIn: true,
             isLoggedIn: true,

+ 217 - 385
dashboard/src/main/home/Home.tsx

@@ -1,5 +1,5 @@
 import React, { Component } from "react";
 import React, { Component } from "react";
-import { RouteComponentProps, withRouter } from "react-router";
+import { Route, RouteComponentProps, Switch, withRouter } from "react-router";
 import styled from "styled-components";
 import styled from "styled-components";
 
 
 import api from "shared/api";
 import api from "shared/api";
@@ -15,24 +15,19 @@ import Dashboard from "./dashboard/Dashboard";
 import WelcomeForm from "./WelcomeForm";
 import WelcomeForm from "./WelcomeForm";
 import Integrations from "./integrations/Integrations";
 import Integrations from "./integrations/Integrations";
 import Templates from "./launch/Launch";
 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 Navbar from "./navbar/Navbar";
-import NewProject from "./new-project/NewProject";
 import ProjectSettings from "./project-settings/ProjectSettings";
 import ProjectSettings from "./project-settings/ProjectSettings";
 import Sidebar from "./sidebar/Sidebar";
 import Sidebar from "./sidebar/Sidebar";
 import PageNotFound from "components/PageNotFound";
 import PageNotFound from "components/PageNotFound";
-import DeleteNamespaceModal from "./modals/DeleteNamespaceModal";
+
 import { fakeGuardedRoute } from "shared/auth/RouteGuard";
 import { fakeGuardedRoute } from "shared/auth/RouteGuard";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
-import EditInviteOrCollaboratorModal from "./modals/EditInviteOrCollaboratorModal";
-import AccountSettingsModal from "./modals/AccountSettingsModal";
 import discordLogo from "../../assets/discord.svg";
 import discordLogo from "../../assets/discord.svg";
-import UsageWarningModal from "./modals/UsageWarningModal";
+import Onboarding from "./onboarding/Onboarding";
+import ModalHandler from "./ModalHandler";
+import { NewProjectFC } from "./new-project/NewProject";
+
 // Guarded components
 // Guarded components
 const GuardedProjectSettings = fakeGuardedRoute("settings", "", [
 const GuardedProjectSettings = fakeGuardedRoute("settings", "", [
   "get",
   "get",
@@ -84,37 +79,6 @@ class Home extends Component<PropsType, StateType> {
     showWelcomeForm: true,
     showWelcomeForm: true,
   };
   };
 
 
-  // TODO: Refactor and prevent flash + multiple reload
-  initializeView = () => {
-    let { currentProject } = this.props;
-
-    if (!currentProject) return;
-
-    api
-      .getInfra(
-        "<token>",
-        {},
-        {
-          project_id: currentProject.id,
-        }
-      )
-      .then((res) => {
-        let creating = false;
-
-        for (var i = 0; i < res.data.length; i++) {
-          creating = res.data[i].status === "creating";
-        }
-        if (creating) {
-          pushFiltered(this.props, "/dashboard", ["project_id"], {
-            tab: "provisioner",
-          });
-        } else if (this.state.ghRedirect) {
-          pushFiltered(this.props, "/integrations", ["project_id"]);
-          this.setState({ ghRedirect: false });
-        }
-      });
-  };
-
   getMetadata = () => {
   getMetadata = () => {
     api
     api
       .getMetadata("<token>", {}, {})
       .getMetadata("<token>", {}, {})
@@ -141,7 +105,7 @@ class Home extends Component<PropsType, StateType> {
       .then((res) => {
       .then((res) => {
         if (res.data) {
         if (res.data) {
           if (res.data.length === 0) {
           if (res.data.length === 0) {
-            pushFiltered(this.props, "/new-project", ["project_id"]);
+            this.redirectToNewProject();
           } else if (res.data.length > 0 && !currentProject) {
           } else if (res.data.length > 0 && !currentProject) {
             setProjects(res.data);
             setProjects(res.data);
 
 
@@ -163,9 +127,7 @@ class Home extends Component<PropsType, StateType> {
                   foundProject = project;
                   foundProject = project;
                 }
                 }
               });
               });
-              setCurrentProject(foundProject || res.data[0], () =>
-                this.initializeView()
-              );
+              setCurrentProject(foundProject || res.data[0]);
             }
             }
           }
           }
         }
         }
@@ -173,92 +135,9 @@ class Home extends Component<PropsType, StateType> {
       .catch(console.log);
       .catch(console.log);
   };
   };
 
 
-  provisionDOCR = async (
-    integrationId: number,
-    tier: string,
-    callback?: any
-  ) => {
-    console.log("Provisioning DOCR...");
-    await api.createDOCR(
-      "<token>",
-      {
-        do_integration_id: integrationId,
-        docr_name: this.props.currentProject.name,
-        docr_subscription_tier: tier,
-      },
-      {
-        project_id: this.props.currentProject.id,
-      }
-    );
-    return callback();
-  };
-
-  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: this.props.currentProject.id,
-      }
-    );
-    return pushFiltered(this.props, "/dashboard", ["project_id"], {
-      tab: "provisioner",
-    });
-  };
-
-  checkDO = () => {
-    let { currentProject } = this.props;
-    if (this.state.handleDO && currentProject?.id) {
-      api
-        .getOAuthIds(
-          "<token>",
-          {},
-          {
-            project_id: currentProject.id,
-          }
-        )
-        .then((res) => {
-          let tgtIntegration = res.data.find((integration: any) => {
-            return integration.client === "do";
-          });
-          let queryString = window.location.search;
-          let urlParams = new URLSearchParams(queryString);
-          let tier = urlParams.get("tier");
-          let region = urlParams.get("region");
-          let clusterName = urlParams.get("cluster_name");
-          let infras = urlParams.getAll("infras");
-          if (infras.length === 2) {
-            this.provisionDOCR(tgtIntegration.id, tier, () => {
-              this.provisionDOKS(tgtIntegration.id, region, clusterName);
-            });
-          } else if (infras[0] === "docr") {
-            this.provisionDOCR(tgtIntegration.id, tier, () => {
-              pushFiltered(this.props, "/dashboard", ["project_id"], {
-                tab: "provisioner",
-              });
-            });
-          } else {
-            this.provisionDOKS(tgtIntegration.id, region, clusterName);
-          }
-        })
-        .catch(console.log);
-      this.setState({ handleDO: false });
-    }
-  };
-
   componentDidMount() {
   componentDidMount() {
+    this.checkOnboarding();
     let { match } = this.props;
     let { match } = this.props;
-    let params = match.params as any;
-    let { cluster } = params;
 
 
     let { user } = this.context;
     let { user } = this.context;
 
 
@@ -280,17 +159,20 @@ class Home extends Component<PropsType, StateType> {
       this.context.setCurrentError(err);
       this.context.setCurrentError(err);
     }
     }
 
 
-    let provision = urlParams.get("provision");
     let defaultProjectId = parseInt(urlParams.get("project_id"));
     let defaultProjectId = parseInt(urlParams.get("project_id"));
-    if (provision === "do") {
-      this.setState({ handleDO: true });
-      this.checkDO();
-    }
 
 
     this.setState({ ghRedirect: urlParams.get("gh_oauth") !== null });
     this.setState({ ghRedirect: urlParams.get("gh_oauth") !== null });
     urlParams.delete("gh_oauth");
     urlParams.delete("gh_oauth");
     this.getProjects(defaultProjectId);
     this.getProjects(defaultProjectId);
     this.getMetadata();
     this.getMetadata();
+
+    if (
+      !this.context.hasFinishedOnboarding &&
+      this.props.history.location.pathname &&
+      !this.props.history.location.pathname.includes("onboarding")
+    ) {
+      this.context.setCurrentModal("RedirectToOnboardingModal");
+    }
   }
   }
 
 
   async checkIfProjectHasBilling(projectId: number) {
   async checkIfProjectHasBilling(projectId: number) {
@@ -310,12 +192,45 @@ class Home extends Component<PropsType, StateType> {
     }
     }
   }
   }
 
 
+  async checkOnboarding() {
+    try {
+      const project_id = this.context?.currentProject?.id;
+      if (!project_id) {
+        return;
+      }
+      const res = await api.getOnboardingState("<token>", {}, { project_id });
+
+      if (res.status === 404) {
+        this.context.setHasFinishedOnboarding(true);
+        return;
+      }
+
+      if (res?.data && res?.data.current_step !== "clean_up") {
+        this.context.setHasFinishedOnboarding(false);
+      } else {
+        this.context.setHasFinishedOnboarding(true);
+      }
+    } catch (error) {}
+  }
+
   // TODO: Need to handle the following cases. Do a deep rearchitecture (Prov -> Dashboard?) if need be:
   // TODO: Need to handle the following cases. Do a deep rearchitecture (Prov -> Dashboard?) if need be:
   // 1. Make sure clicking cluster in drawer shows cluster-dashboard
   // 1. Make sure clicking cluster in drawer shows cluster-dashboard
   // 2. Make sure switching projects shows appropriate initial view (dashboard || provisioner)
   // 2. Make sure switching projects shows appropriate initial view (dashboard || provisioner)
   // 3. Make sure initializing from URL (DO oauth) displays the appropriate initial view
   // 3. Make sure initializing from URL (DO oauth) displays the appropriate initial view
   componentDidUpdate(prevProps: PropsType) {
   componentDidUpdate(prevProps: PropsType) {
+    if (
+      !this.context.hasFinishedOnboarding &&
+      prevProps.match.url !== this.props.match.url &&
+      this.props.history.location.pathname &&
+      !this.props.history.location.pathname.includes("onboarding") &&
+      !this.props.history.location.pathname.includes("new-project") &&
+      !this.props.history.location.pathname.includes("project-settings")
+    ) {
+      this.context.setCurrentModal("RedirectToOnboardingModal");
+    }
+
     if (prevProps.currentProject?.id !== this.props.currentProject?.id) {
     if (prevProps.currentProject?.id !== this.props.currentProject?.id) {
+      this.checkOnboarding();
       this.checkIfProjectHasBilling(this?.context?.currentProject?.id)
       this.checkIfProjectHasBilling(this?.context?.currentProject?.id)
         .then((isBillingEnabled) => {
         .then((isBillingEnabled) => {
           if (isBillingEnabled) {
           if (isBillingEnabled) {
@@ -344,280 +259,94 @@ class Home extends Component<PropsType, StateType> {
       prevProps.currentProject !== this.props.currentProject ||
       prevProps.currentProject !== this.props.currentProject ||
       (!prevProps.currentCluster && this.props.currentCluster)
       (!prevProps.currentCluster && this.props.currentCluster)
     ) {
     ) {
-      if (this.state.handleDO) {
-        this.checkDO();
-      } else {
-        this.initializeView();
-        this.getMetadata();
-      }
+      this.getMetadata();
     }
     }
   }
   }
 
 
-  // TODO: move into ClusterDashboard
-  renderDashboard = () => {
-    let { currentCluster } = this.context;
-    if (currentCluster?.id === -1) {
-      return <Loading />;
-    } else if (!currentCluster || !currentCluster.name) {
-      return (
-        <DashboardWrapper>
-          <PageNotFound />
-        </DashboardWrapper>
-      );
-    }
-    return (
-      <DashboardWrapper>
-        <ClusterDashboard
-          currentCluster={currentCluster}
-          setSidebar={(x: boolean) => this.setState({ forceSidebar: x })}
-          currentView={this.props.currentRoute}
-          // setCurrentView={(x: string) => this.setState({ currentView: x })}
-        />
-      </DashboardWrapper>
-    );
-  };
+  projectOverlayCall = async () => {
+    let { user, setProjects, setCurrentProject } = this.context;
+    try {
+      const res = await api.getProjects("<token>", {}, { id: user.userId });
+      if (!res.data) {
+        this.context.setCurrentModal(null, null);
+        return;
+      }
 
 
-  renderContents = () => {
-    let currentView = this.props.currentRoute;
-
-    if (this.context.currentProject && currentView !== "new-project") {
-      if (
-        currentView === "cluster-dashboard" ||
-        currentView === "applications" ||
-        currentView === "jobs" ||
-        currentView === "env-groups"
-      ) {
-        return this.renderDashboard();
-      } else if (currentView === "dashboard") {
-        return (
-          <DashboardWrapper>
-            <Dashboard
-              projectId={this.context.currentProject?.id}
-              setRefreshClusters={(x: boolean) =>
-                this.setState({ forceRefreshClusters: x })
-              }
-            />
-          </DashboardWrapper>
-        );
-      } else if (currentView === "integrations") {
-        return <GuardedIntegrations />;
-      } else if (currentView === "project-settings") {
-        return <GuardedProjectSettings />;
+      setProjects(res.data);
+      if (!res.data.length) {
+        setCurrentProject(null, () => this.redirectToNewProject());
+      } else {
+        setCurrentProject(res.data[0]);
       }
       }
-      return <Templates />;
-    } else if (currentView === "new-project") {
-      return <NewProject />;
+      this.context.setCurrentModal(null, null);
+    } catch (error) {
+      /** @todo Centralize with error handler */
+      console.log(error);
     }
     }
   };
   };
 
 
-  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
-                />
-              </>
-            )}
-        </>
-      );
+  handleDelete = async () => {
+    let { setCurrentModal, currentProject } = this.context;
+    localStorage.removeItem(currentProject.id + "-cluster");
+    try {
+      await api.deleteProject("<token>", {}, { id: currentProject?.id });
+      this.projectOverlayCall();
+    } catch (error) {
+      /** @todo Centralize with error handler */
+      console.log(error);
     }
     }
-  };
 
 
-  projectOverlayCall = () => {
-    let { user, setProjects, setCurrentProject } = this.context;
-    api
-      .getProjects("<token>", {}, { id: user.userId })
-      .then((res) => {
-        if (res.data) {
-          setProjects(res.data);
-          if (res.data.length > 0) {
-            setCurrentProject(res.data[0]);
-          } else {
-            setCurrentProject(null, () =>
-              pushFiltered(this.props, "/new-project", ["project_id"])
-            );
-          }
-          this.context.setCurrentModal(null, null);
+    try {
+      const res = await api.getClusters<
+        {
+          infra_id?: number;
+          name: string;
+        }[]
+      >("<token>", {}, { id: currentProject?.id });
+
+      const destroyInfraPromises = res.data.map((cluster) => {
+        if (!cluster.infra_id) {
+          return undefined;
         }
         }
-      })
-      .catch(console.log);
-  };
 
 
-  handleDelete = () => {
-    let { setCurrentModal, currentProject } = this.context;
-    localStorage.removeItem(currentProject.id + "-cluster");
-    api
-      .deleteProject("<token>", {}, { id: currentProject.id })
-      .then(this.projectOverlayCall)
-      .catch(console.log);
+        return api.destroyInfra(
+          "<token>",
+          { name: cluster.name },
+          { project_id: currentProject.id, infra_id: cluster.infra_id }
+        );
+      });
 
 
-    // Loop through and delete infra of all clusters we've provisioned
-    api
-      .getClusters("<token>", {}, { id: currentProject.id })
-      .then((res) => {
-        // TODO: promise.map
-        for (var i = 0; i < res.data.length; i++) {
-          let cluster = res.data[i];
-          if (!cluster.infra_id) continue;
-
-          // Handle destroying infra we've provisioned
-          api
-            .destroyInfra(
-              "<token>",
-              { name: cluster.name },
-              {
-                project_id: currentProject.id,
-                infra_id: cluster.infra_id,
-              }
-            )
-            .then(() =>
-              console.log("destroyed provisioned infra:", cluster.infra_id)
-            )
-            .catch(console.log);
-        }
-      })
-      .catch(console.log);
+      await Promise.all(destroyInfraPromises);
+    } catch (error) {
+      console.log(error);
+    }
     setCurrentModal(null, null);
     setCurrentModal(null, null);
     pushFiltered(this.props, "/dashboard", []);
     pushFiltered(this.props, "/dashboard", []);
   };
   };
 
 
+  redirectToNewProject = () => {
+    pushFiltered(this.props, "/new-project", ["project_id"]);
+  };
+
+  redirectToOnboarding = () => {
+    pushFiltered(this.props, "/onboarding", []);
+  };
+
   render() {
   render() {
     let {
     let {
       currentModal,
       currentModal,
       setCurrentModal,
       setCurrentModal,
       currentProject,
       currentProject,
       currentOverlay,
       currentOverlay,
-      setCurrentOverlay,
+      projects,
     } = this.context;
     } = this.context;
 
 
+    const { cluster, baseRoute } = this.props.match.params as any;
     return (
     return (
       <StyledHome>
       <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 && (
         {currentOverlay && (
           <ConfirmOverlay
           <ConfirmOverlay
             show={true}
             show={true}
@@ -627,14 +356,117 @@ class Home extends Component<PropsType, StateType> {
           />
           />
         )}
         )}
 
 
-        {this.renderSidebar()}
+        {/* Render sidebar when there's at least one project */}
+        {projects?.length > 0 && baseRoute !== "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>
         <ViewWrapper>
           <Navbar
           <Navbar
             logOut={this.props.logOut}
             logOut={this.props.logOut}
             currentView={this.props.currentRoute} // For form feedback
             currentView={this.props.currentRoute} // For form feedback
           />
           />
-          {this.renderContents()}
+
+          <Switch>
+            <Route
+              path="/new-project"
+              render={() => {
+                return <NewProjectFC />;
+              }}
+            ></Route>
+            <Route
+              path="/onboarding"
+              render={() => {
+                return <Onboarding />;
+              }}
+            />
+            <Route
+              path="/dashboard"
+              render={() => {
+                return (
+                  <DashboardWrapper>
+                    <Dashboard
+                      projectId={this.context.currentProject?.id}
+                      setRefreshClusters={(x: boolean) =>
+                        this.setState({ forceRefreshClusters: x })
+                      }
+                    />
+                  </DashboardWrapper>
+                );
+              }}
+            />
+            <Route
+              path={[
+                "/cluster-dashboard",
+                "/applications",
+                "/jobs",
+                "/env-groups",
+              ]}
+              render={() => {
+                let { currentCluster } = this.context;
+                if (currentCluster?.id === -1) {
+                  return <Loading />;
+                } else if (!currentCluster || !currentCluster.name) {
+                  return (
+                    <DashboardWrapper>
+                      <PageNotFound />
+                    </DashboardWrapper>
+                  );
+                }
+                return (
+                  <DashboardWrapper>
+                    <ClusterDashboard
+                      currentCluster={currentCluster}
+                      setSidebar={(x: boolean) =>
+                        this.setState({ forceSidebar: x })
+                      }
+                      currentView={this.props.currentRoute}
+                      // setCurrentView={(x: string) => this.setState({ currentView: x })}
+                    />
+                  </DashboardWrapper>
+                );
+              }}
+            />
+            <Route
+              path={"/integrations"}
+              render={() => <GuardedIntegrations />}
+            />
+            <Route
+              path={"/project-settings"}
+              render={() => <GuardedProjectSettings />}
+            />
+            <Route path={"*"} render={() => <Templates />} />
+          </Switch>
         </ViewWrapper>
         </ViewWrapper>
 
 
         <ConfirmOverlay
         <ConfirmOverlay

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

@@ -0,0 +1,194 @@
+import React, { useContext, useEffect, useState } 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";
+import RedirectToOnboardingModal from "./modals/RedirectToOnboardingModal";
+
+import UsageWarningModal from "./modals/UsageWarningModal";
+import api from "shared/api";
+import { AxiosError } from "axios";
+
+const ModalHandler: React.FC<{
+  setRefreshClusters: (x: boolean) => void;
+}> = ({ setRefreshClusters }) => {
+  const [isAuth] = useAuth();
+  const {
+    currentModal,
+    setCurrentModal,
+    currentProject,
+    setHasFinishedOnboarding,
+  } = useContext(Context);
+
+  const [modal, setModal] = useState("");
+
+  useEffect(() => {
+    const projectOnboarding = localStorage.getItem(
+      `onboarding-${currentProject?.id}`
+    );
+    const parsedProjectOnboarding = JSON.parse(projectOnboarding);
+    if (parsedProjectOnboarding?.StepHandler?.finishedOnboarding === false) {
+      setCurrentModal("RedirectToOnboarding");
+    }
+  }, [currentProject?.id]);
+
+  const checkOnboarding = async () => {
+    try {
+      const project_id = currentProject?.id;
+      const res = await api.getOnboardingState("<token>", {}, { project_id });
+
+      if (res?.data && res?.data.current_step !== "clean_up") {
+        return {
+          finished: false,
+        };
+      } else {
+        return {
+          finished: true,
+        };
+      }
+    } catch (error) {
+      const err: AxiosError = error;
+      if (err.response.status === 404) {
+        return {
+          finished: true,
+        };
+      }
+    }
+  };
+
+  useEffect(() => {
+    if (currentModal === "RedirectToOnboardingModal") {
+      if (currentProject?.id) {
+        checkOnboarding().then((status) => {
+          if (status?.finished) {
+            setCurrentModal(null, null);
+            setHasFinishedOnboarding(true);
+          } else {
+            setHasFinishedOnboarding(false);
+            setModal("RedirectToOnboardingModal");
+          }
+        });
+      }
+    } else {
+      setModal(currentModal);
+    }
+  }, [currentModal, currentProject]);
+
+  return (
+    <>
+      {modal === "RedirectToOnboardingModal" && (
+        <Modal width="600px" height="180px" title="You're almost ready...">
+          <RedirectToOnboardingModal />
+        </Modal>
+      )}
+
+      {modal === "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 */}
+      {isAuth("cluster", "", ["get", "delete"]) &&
+        modal === "UpdateClusterModal" && (
+          <Modal
+            onRequestClose={() => setCurrentModal(null, null)}
+            width="565px"
+            height="275px"
+            title="Cluster Settings"
+          >
+            <UpdateClusterModal
+              setRefreshClusters={(x: boolean) => setRefreshClusters(x)}
+            />
+          </Modal>
+        )}
+      {modal === "IntegrationsModal" && (
+        <Modal
+          onRequestClose={() => setCurrentModal(null, null)}
+          width="760px"
+          height="380px"
+          title="Add a New Integration"
+        >
+          <IntegrationsModal />
+        </Modal>
+      )}
+      {modal === "IntegrationsInstructionsModal" && (
+        <Modal
+          onRequestClose={() => setCurrentModal(null, null)}
+          width="760px"
+          height="650px"
+          title="Connecting to an Image Registry"
+        >
+          <IntegrationsInstructionsModal />
+        </Modal>
+      )}
+      {isAuth("namespace", "", ["get", "create"]) &&
+        modal === "NamespaceModal" && (
+          <Modal
+            onRequestClose={() => setCurrentModal(null, null)}
+            width="600px"
+            height="220px"
+            title="Add Namespace"
+          >
+            <NamespaceModal />
+          </Modal>
+        )}
+      {isAuth("namespace", "", ["get", "delete"]) &&
+        modal === "DeleteNamespaceModal" && (
+          <Modal
+            onRequestClose={() => setCurrentModal(null, null)}
+            width="700px"
+            height="280px"
+            title="Delete Namespace"
+          >
+            <DeleteNamespaceModal />
+          </Modal>
+        )}
+
+      {modal === "EditInviteOrCollaboratorModal" && (
+        <Modal
+          onRequestClose={() => setCurrentModal(null, null)}
+          width="600px"
+          height="250px"
+        >
+          <EditInviteOrCollaboratorModal />
+        </Modal>
+      )}
+      {modal === "AccountSettingsModal" && (
+        <Modal
+          onRequestClose={() => setCurrentModal(null, null)}
+          width="760px"
+          height="440px"
+          title="Account Settings"
+        >
+          <AccountSettingsModal />
+        </Modal>
+      )}
+
+      {modal === "UsageWarningModal" && (
+        <Modal
+          onRequestClose={() => setCurrentModal(null, null)}
+          width="760px"
+          height="530px"
+          title="Usage Warning"
+        >
+          <UsageWarningModal />
+        </Modal>
+      )}
+    </>
+  );
+};
+
+export default ModalHandler;

+ 6 - 1
dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -160,7 +160,12 @@ const ChartList: React.FunctionComponent<Props> = ({
       },
       },
       onmessage: (evt: MessageEvent) => {
       onmessage: (evt: MessageEvent) => {
         let event = JSON.parse(evt.data);
         let event = JSON.parse(evt.data);
-        let object = event.Object;
+        let object = event?.Object;
+
+        if (!object?.metadata?.kind) {
+          return;
+        }
+
         object.metadata.kind = event.Kind;
         object.metadata.kind = event.Kind;
 
 
         setControllers((oldControllers) => ({
         setControllers((oldControllers) => ({

+ 5 - 2
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx

@@ -100,7 +100,7 @@ class EnvGroupDashboard extends Component<PropsType, StateType> {
             namespace={this.state.namespace}
             namespace={this.state.namespace}
             sortType={this.state.sortType}
             sortType={this.state.sortType}
             setExpandedEnvGroup={(envGroup: any) => {
             setExpandedEnvGroup={(envGroup: any) => {
-              this.setState({ expandedEnvGroup: envGroup })
+              this.setState({ expandedEnvGroup: envGroup });
             }}
             }}
           />
           />
         </>
         </>
@@ -112,7 +112,10 @@ class EnvGroupDashboard extends Component<PropsType, StateType> {
     if (this.state.expandedEnvGroup) {
     if (this.state.expandedEnvGroup) {
       return (
       return (
         <ExpandedEnvGroup
         <ExpandedEnvGroup
-          namespace={this.state.expandedEnvGroup?.metadata?.namespace || this.state.namespace}
+          namespace={
+            this.state.expandedEnvGroup?.metadata?.namespace ||
+            this.state.namespace
+          }
           currentCluster={this.props.currentCluster}
           currentCluster={this.props.currentCluster}
           envGroup={this.state.expandedEnvGroup}
           envGroup={this.state.expandedEnvGroup}
           closeExpanded={() => this.setState({ expandedEnvGroup: null })}
           closeExpanded={() => this.setState({ expandedEnvGroup: null })}

+ 8 - 1
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -16,6 +16,7 @@ import TitleSection from "components/TitleSection";
 
 
 import { pushFiltered, pushQueryParams } from "shared/routing";
 import { pushFiltered, pushQueryParams } from "shared/routing";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+import { SharedStatus } from "../onboarding/steps/ProvisionResources/forms/SharedStatus";
 
 
 type PropsType = RouteComponentProps &
 type PropsType = RouteComponentProps &
   WithAuthProps & {
   WithAuthProps & {
@@ -105,7 +106,13 @@ class Dashboard extends Component<PropsType, StateType> {
 
 
   renderTabContents = () => {
   renderTabContents = () => {
     if (this.currentTab() === "provisioner") {
     if (this.currentTab() === "provisioner") {
-      return <Provisioner setRefreshClusters={this.props.setRefreshClusters} />;
+      return (
+        <SharedStatus
+          filter={[]}
+          project_id={this.props.projectId}
+          setInfraStatus={(val: string) => null}
+        />
+      );
     } else if (this.currentTab() === "create-cluster") {
     } else if (this.currentTab() === "create-cluster") {
       let helperText = "Create a cluster to link to this project";
       let helperText = "Create a cluster to link to this project";
       let helperIcon = "info";
       let helperIcon = "info";

+ 0 - 3
dashboard/src/main/home/integrations/create-integration/GCRForm.tsx

@@ -16,7 +16,6 @@ type PropsType = {
 
 
 type StateType = {
 type StateType = {
   credentialsName: string;
   credentialsName: string;
-  gcpRegion: string;
   serviceAccountKey: string;
   serviceAccountKey: string;
   gcpProjectID: string;
   gcpProjectID: string;
   url: string;
   url: string;
@@ -25,7 +24,6 @@ type StateType = {
 export default class GCRForm extends Component<PropsType, StateType> {
 export default class GCRForm extends Component<PropsType, StateType> {
   state = {
   state = {
     credentialsName: "",
     credentialsName: "",
-    gcpRegion: "",
     serviceAccountKey: "",
     serviceAccountKey: "",
     gcpProjectID: "",
     gcpProjectID: "",
     url: "",
     url: "",
@@ -48,7 +46,6 @@ export default class GCRForm extends Component<PropsType, StateType> {
       .createGCPIntegration(
       .createGCPIntegration(
         "<token>",
         "<token>",
         {
         {
-          gcp_region: this.state.gcpRegion,
           gcp_key_data: this.state.serviceAccountKey,
           gcp_key_data: this.state.serviceAccountKey,
           gcp_project_id: this.state.gcpProjectID,
           gcp_project_id: this.state.gcpProjectID,
         },
         },

+ 1 - 18
dashboard/src/main/home/integrations/edit-integration/GCRForm.tsx

@@ -16,7 +16,6 @@ type PropsType = {
 
 
 type StateType = {
 type StateType = {
   credentialsName: string;
   credentialsName: string;
-  gcpRegion: string;
   serviceAccountKey: string;
   serviceAccountKey: string;
   gcpProjectID: string;
   gcpProjectID: string;
   url: string;
   url: string;
@@ -25,22 +24,15 @@ type StateType = {
 export default class GCRForm extends Component<PropsType, StateType> {
 export default class GCRForm extends Component<PropsType, StateType> {
   state = {
   state = {
     credentialsName: "",
     credentialsName: "",
-    gcpRegion: "",
     serviceAccountKey: "",
     serviceAccountKey: "",
     gcpProjectID: "",
     gcpProjectID: "",
     url: "",
     url: "",
   };
   };
 
 
   isDisabled = (): boolean => {
   isDisabled = (): boolean => {
-    let {
-      credentialsName,
-      gcpRegion,
-      gcpProjectID,
-      serviceAccountKey,
-    } = this.state;
+    let { credentialsName, gcpProjectID, serviceAccountKey } = this.state;
     if (
     if (
       credentialsName === "" ||
       credentialsName === "" ||
-      gcpRegion === "" ||
       serviceAccountKey === "" ||
       serviceAccountKey === "" ||
       gcpProjectID === ""
       gcpProjectID === ""
     ) {
     ) {
@@ -58,7 +50,6 @@ export default class GCRForm extends Component<PropsType, StateType> {
       .createGCPIntegration(
       .createGCPIntegration(
         "<token>",
         "<token>",
         {
         {
-          gcp_region: this.state.gcpRegion,
           gcp_key_data: this.state.serviceAccountKey,
           gcp_key_data: this.state.serviceAccountKey,
           gcp_project_id: this.state.gcpProjectID,
           gcp_project_id: this.state.gcpProjectID,
         },
         },
@@ -106,14 +97,6 @@ export default class GCRForm extends Component<PropsType, StateType> {
           />
           />
           <Heading>GCP Settings</Heading>
           <Heading>GCP Settings</Heading>
           <Helper>Service account credentials for GCP permissions.</Helper>
           <Helper>Service account credentials for GCP permissions.</Helper>
-          <InputRow
-            type="text"
-            value={this.state.gcpRegion}
-            setValue={(gcpRegion: string) => this.setState({ gcpRegion })}
-            label="📍 GCP Region"
-            placeholder="ex: uranus-north3"
-            width="100%"
-          />
           <TextArea
           <TextArea
             value={this.state.serviceAccountKey}
             value={this.state.serviceAccountKey}
             setValue={(serviceAccountKey: string) =>
             setValue={(serviceAccountKey: string) =>

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

@@ -197,4 +197,4 @@ const Placeholder = styled.div`
 const Bold = styled.div`
 const Bold = styled.div`
   font-weight: 600;
   font-weight: 600;
   margin-bottom: 7px;
   margin-bottom: 7px;
-`;
+`;

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

@@ -129,4 +129,4 @@ const ModalTitle = styled.div`
   position: relative;
   position: relative;
   white-space: nowrap;
   white-space: nowrap;
   text-overflow: ellipsis;
   text-overflow: ellipsis;
-`;
+`;

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

@@ -89,4 +89,4 @@ const Placeholder = styled.div`
 const Bold = styled.div`
 const Bold = styled.div`
   font-weight: 600;
   font-weight: 600;
   margin-bottom: 7px;
   margin-bottom: 7px;
-`;
+`;

+ 15 - 15
dashboard/src/main/home/modals/IntegrationsModal.tsx

@@ -49,20 +49,20 @@ export default class IntegrationsModal extends Component<PropsType, StateType> {
 
 
         if (!disabled) {
         if (!disabled) {
           return (
           return (
-          <IntegrationOption
-            key={i}
-            disabled={disabled}
-            onClick={() => {
-              if (!disabled) {
-                setCurrentIntegration(integration.service);
-                this.context.setCurrentModal(null, null);
-              }
-            }}
-          >
-            <Icon src={icon && icon} />
-            <Label>{integrationList[integration.service].label}</Label>
-          </IntegrationOption>
-        );
+            <IntegrationOption
+              key={i}
+              disabled={disabled}
+              onClick={() => {
+                if (!disabled) {
+                  setCurrentIntegration(integration.service);
+                  this.context.setCurrentModal(null, null);
+                }
+              }}
+            >
+              <Icon src={icon && icon} />
+              <Label>{integrationList[integration.service].label}</Label>
+            </IntegrationOption>
+          );
         }
         }
       });
       });
     }
     }
@@ -127,4 +127,4 @@ const Subtitle = styled.div`
   overflow: hidden;
   overflow: hidden;
   white-space: nowrap;
   white-space: nowrap;
   text-overflow: ellipsis;
   text-overflow: ellipsis;
-`;
+`;

+ 9 - 7
dashboard/src/main/home/modals/Modal.tsx

@@ -2,7 +2,7 @@ import React, { Component } from "react";
 import styled from "styled-components";
 import styled from "styled-components";
 
 
 type PropsType = {
 type PropsType = {
-  onRequestClose: () => void;
+  onRequestClose?: () => void;
   width?: string;
   width?: string;
   height?: string;
   height?: string;
   title?: string;
   title?: string;
@@ -26,6 +26,7 @@ export default class Modal extends Component<PropsType, StateType> {
 
 
   handleClickOutside = (event: any) => {
   handleClickOutside = (event: any) => {
     if (
     if (
+      this.props.onRequestClose &&
       this.wrapperRef &&
       this.wrapperRef &&
       this.wrapperRef.current &&
       this.wrapperRef.current &&
       !this.wrapperRef.current.contains(event.target)
       !this.wrapperRef.current.contains(event.target)
@@ -39,14 +40,14 @@ export default class Modal extends Component<PropsType, StateType> {
     return (
     return (
       <Overlay>
       <Overlay>
         <StyledModal ref={this.wrapperRef} width={width} height={height}>
         <StyledModal ref={this.wrapperRef} width={width} height={height}>
-          <CloseButton onClick={this.props.onRequestClose}>
-            <i className="material-icons">close</i>
-          </CloseButton>
-          { 
-            this.props.title && (
-              <ModalTitle>{this.props.title}</ModalTitle>
+          {
+            this.props.onRequestClose && (
+              <CloseButton onClick={this.props.onRequestClose}>
+                <i className="material-icons">close</i>
+              </CloseButton>
             )
             )
           }
           }
+          {this.props.title && <ModalTitle>{this.props.title}</ModalTitle>}
           {this.props.children}
           {this.props.children}
         </StyledModal>
         </StyledModal>
       </Overlay>
       </Overlay>
@@ -108,6 +109,7 @@ const StyledModal = styled.div`
     props.height ? props.height : "425px"};
     props.height ? props.height : "425px"};
   overflow: visible;
   overflow: visible;
   padding: 25px 32px;
   padding: 25px 32px;
+  z-index: 999;
   font-size: 13px;
   font-size: 13px;
   border-radius: 10px;
   border-radius: 10px;
   background: #202227;
   background: #202227;

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

@@ -147,4 +147,4 @@ const Subtitle = styled.div`
   white-space: nowrap;
   white-space: nowrap;
   text-overflow: ellipsis;
   text-overflow: ellipsis;
   margin-bottom: -10px;
   margin-bottom: -10px;
-`;
+`;

+ 72 - 0
dashboard/src/main/home/modals/RedirectToOnboardingModal.tsx

@@ -0,0 +1,72 @@
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+
+import api from "../../../shared/api";
+import Loading from "../../../components/Loading";
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
+import { Link } from "react-router-dom";
+import { Context } from "shared/Context";
+
+const RedirectToOnboardingModal = () => {
+  const { setCurrentModal } = useContext(Context);
+
+  return (
+    <>
+      <Helper>
+        You need to complete the onboarding process in order to use Porter.
+      </Helper>
+      <ContinueButton
+        as={Link}
+        to="/onboarding"
+        onClick={() => setCurrentModal(null, null)}
+      >
+        <i className="material-icons">east</i>
+        Continue Setup
+      </ContinueButton>
+    </>
+  );
+};
+
+export default RedirectToOnboardingModal;
+
+const ContinueButton = styled.a`
+  height: 35px;
+  font-size: 13px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  color: white;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 6px 15px 7px 15px;
+  text-align: left;
+  border: 0;
+  margin-top: 25px;
+  width: 160px;
+  border-radius: 5px;
+  background: #616feecc;
+  box-shadow: 0 2px 5px 0 #00000030;
+  cursor: pointer;
+  user-select: none;
+  :focus {
+    outline: 0;
+  }
+  :hover {
+    filter: brightness(120%);
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 14px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 7px;
+    margin-left: -5px;
+    justify-content: center;
+  }
+`;

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

@@ -223,4 +223,4 @@ const Subtitle = styled.div`
   white-space: nowrap;
   white-space: nowrap;
   text-overflow: ellipsis;
   text-overflow: ellipsis;
   margin-bottom: -10px;
   margin-bottom: -10px;
-`;
+`;

+ 2 - 2
dashboard/src/main/home/modals/UsageWarningModal.tsx

@@ -65,8 +65,8 @@ const UpgradeChartModal: React.FC<{}> = () => {
   const [usage, setUsage] = useState<UsageData>(null);
   const [usage, setUsage] = useState<UsageData>(null);
 
 
   useEffect(() => {
   useEffect(() => {
-    if (currentModalData.usage) {
-      const currentUsage: UsageData = currentModalData.usage;
+    if (currentModalData?.usage) {
+      const currentUsage: UsageData = currentModalData?.usage;
       setUsage(currentUsage);
       setUsage(currentUsage);
     }
     }
   }, [currentModalData?.usage]);
   }, [currentModalData?.usage]);

+ 205 - 73
dashboard/src/main/home/new-project/NewProject.tsx

@@ -1,85 +1,200 @@
-import React, { Component } from "react";
+import React, { useContext, useMemo, useState } from "react";
+
+import { useRouting } from "shared/routing";
+import api from "shared/api";
+import SaveButton from "components/SaveButton";
+
+import backArrow from "assets/back_arrow.png";
 import styled from "styled-components";
 import styled from "styled-components";
 
 
 import gradient from "assets/gradient.png";
 import gradient from "assets/gradient.png";
+import PageIllustration from "components/PageIllustration";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 import { isAlphanumeric } from "shared/common";
 import { isAlphanumeric } from "shared/common";
 
 
 import InputRow from "components/form-components/InputRow";
 import InputRow from "components/form-components/InputRow";
 import Helper from "components/form-components/Helper";
 import Helper from "components/form-components/Helper";
-import ProvisionerSettings from "../provisioner/ProvisionerSettings";
 import TitleSection from "components/TitleSection";
 import TitleSection from "components/TitleSection";
 
 
-type PropsType = {};
-
-type StateType = {
-  projectName: string;
-  selectedProvider: string | null;
+type ValidationError = {
+  hasError: boolean;
+  description?: string;
 };
 };
 
 
-export default class NewProject extends Component<PropsType, StateType> {
-  state = {
-    projectName: "",
-    selectedProvider: null as string | null,
+export const NewProjectFC = () => {
+  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,
+    };
   };
   };
 
 
-  componentDidMount() {
-    window.analytics.track("provision_new-project", {
-      userId: this.context.user?.id,
-    });
-  }
+  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);
+      setButtonStatus("successful");
+      pushFiltered("/onboarding", []);
+    } catch (error) {
+      setButtonStatus("Couldn't create project, try again.");
+      console.log(error);
+    }
+  };
 
 
-  render() {
-    let { capabilities } = this.context;
-    let { projectName } = this.state;
-    return (
+  return (
+    <Wrapper>
       <StyledNewProject>
       <StyledNewProject>
-        <TitleSection>New Project</TitleSection>
-        <Helper>
-          Project name
-          <Warning
-            highlight={
-              !isAlphanumeric(this.state.projectName) &&
-              this.state.projectName !== ""
-            }
-          >
-            (lowercase letters, numbers, and "-" only)
-          </Warning>
-          <Required>*</Required>
-        </Helper>
-        <InputWrapper>
-          <ProjectIcon>
-            <ProjectImage src={gradient} />
-            <Letter>
-              {this.state.projectName
-                ? this.state.projectName[0].toUpperCase()
-                : "-"}
-            </Letter>
-          </ProjectIcon>
-          <InputRow
-            type="string"
-            value={this.state.projectName}
-            setValue={(x: string) => this.setState({ projectName: x })}
-            placeholder="ex: perspective-vortex"
-            width="470px"
+        <PageIllustration />
+        <FadeWrapper>
+          {!isFirstProject && (
+            <BackButton
+              onClick={() => {
+                pushFiltered("/dashboard", []);
+              }}
+            >
+              <BackButtonImg src={backArrow} />
+            </BackButton>
+          )}
+          <TitleSection>New 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="470px"
+              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!"
           />
           />
-        </InputWrapper>
-        <ProvisionerSettings
-          isInNewProject={true}
-          projectName={projectName}
-          provisioner={capabilities?.provisioner}
-        />
-        <Br />
+        </SlideWrapper>
       </StyledNewProject>
       </StyledNewProject>
-    );
+    </Wrapper>
+  );
+};
+
+const Wrapper = styled.div`
+  max-width: 700px;
+  width: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-top: -6%;
+  padding-bottom: 5%;
+  min-width: 300px;
+  position: relative;
+`;
+
+const FadeWrapper = styled.div<{ delay?: string }>`
+  opacity: 0;
+  animation: fadeIn 0.5s ${(props) => props.delay || "0.2s"};
+  animation-fill-mode: forwards;
+
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
   }
   }
-}
+`;
 
 
-NewProject.contextType = Context;
+const SlideWrapper = styled.div<{ delay?: string }>`
+  opacity: 0;
+  animation: slideIn 0.7s 1.3s;
+  animation-fill-mode: forwards;
 
 
-const Br = styled.div`
-  width: 100%;
-  height: 100px;
+  @keyframes slideIn {
+    from {
+      opacity: 0;
+      transform: translateX(30px);
+    }
+    to {
+      opacity: 1;
+      transform: translateX(0);
+    }
+  }
 `;
 `;
 
 
 const Required = styled.div`
 const Required = styled.div`
@@ -134,19 +249,36 @@ const Warning = styled.span`
     props.makeFlush ? "" : "5px"};
     props.makeFlush ? "" : "5px"};
 `;
 `;
 
 
-const Title = styled.div`
-  font-size: 20px;
-  font-weight: 500;
-  font-family: "Work Sans", sans-serif;
-  color: #ffffff;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-`;
-
 const StyledNewProject = styled.div`
 const StyledNewProject = styled.div`
-  width: calc(90% - 130px);
   min-width: 300px;
   min-width: 300px;
   position: relative;
   position: relative;
-  margin-top: calc(50vh - 340px);
+`;
+
+const NewProjectSaveButton = styled(SaveButton)`
+  margin-top: 24px;
+`;
+
+const BackButton = styled.div`
+  margin-bottom: 24px;
+  display: flex;
+  width: 36px;
+  cursor: pointer;
+  height: 36px;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const BackButtonImg = styled.img`
+  width: 16px;
+  opacity: 0.75;
 `;
 `;

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

@@ -0,0 +1,146 @@
+import Loading from "components/Loading";
+import React, { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import styled from "styled-components";
+import { devtools } from "valtio/utils";
+import Routes from "./Routes";
+import { OFState } from "./state";
+import { useSteps } from "./state/StepHandler";
+import { Onboarding as OnboardingSaveType } from "./types";
+
+const Onboarding = () => {
+  const context = useContext(Context);
+  const [isLoading, setIsLoading] = useState(true);
+  useSteps(isLoading);
+
+  useEffect(() => {
+    let unsub = devtools(OFState, "Onboarding flow state");
+    return () => {
+      if (typeof unsub === "function") {
+        unsub();
+      }
+    };
+  }, []);
+
+  const getData = async ({
+    id: project_id,
+    name: project_name,
+  }: {
+    id: number;
+    name: string;
+  }): Promise<OnboardingSaveType> => {
+    let odata = null;
+
+    // Get general onboarding data
+    try {
+      const response = await api.getOnboardingState(
+        "<token>",
+        {},
+        { project_id: project_id }
+      );
+
+      if (response.data) {
+        odata = response.data;
+      }
+    } catch (error) {
+      console.error(
+        "Gouldn't get any previous state, going with a brand new onboarding!"
+      );
+    }
+
+    let registry_connection_data = null;
+    if (odata?.registry_connection_id) {
+      // Get subflows data
+      try {
+        const response = await api.getOnboardingRegistry(
+          "<token>",
+          {},
+          {
+            project_id: project_id,
+            registry_connection_id: odata.registry_connection_id,
+          }
+        );
+        //console.log(response);
+        if (response.data) {
+          registry_connection_data = response.data;
+        }
+      } catch (error) {
+        console.error("Couldn't get registry connection data");
+      }
+    }
+    let provision_connection_data = null;
+    if (odata?.registry_infra_id) {
+      try {
+        const response = await api.getOnboardingInfra(
+          "<token>",
+          {},
+          {
+            project_id: project_id,
+            registry_infra_id: odata.registry_infra_id,
+          }
+        );
+
+        if (response.data) {
+          provision_connection_data = response.data;
+        }
+      } catch (error) {
+        console.error("Couldn't get infra data");
+      }
+    }
+    return {
+      project_id,
+      project_name,
+      ...(odata || {}),
+      ...({ registry_connection_data } || {}),
+      ...({ provision_connection_data } || {}),
+    };
+  };
+
+  useEffect(() => {
+    if (context.currentProject) {
+      getData(context.currentProject).then((data) => {
+        OFState.actions.initializeState(data);
+        setIsLoading(false);
+      });
+    }
+    return () => {
+      OFState.actions.clearState();
+    };
+  }, [context.currentProject]);
+
+  useEffect(() => {
+    if (context?.user?.email) {
+      OFState.StateHandler.user_email = context.user.email;
+    }
+  }, [context.user]);
+
+  return (
+    <StyledOnboarding>{isLoading ? <Loading /> : <Routes />}</StyledOnboarding>
+  );
+};
+
+export default Onboarding;
+
+const ViewWrapper = styled.div`
+  width: 100%;
+  overflow-y: auto;
+  display: flex;
+  justify-content: center;
+  margin-top: -10vh;
+  height: 111%;
+  padding-top: 600px;
+  padding-bottom: 300px;
+`;
+
+const StyledOnboarding = styled.div`
+  max-width: 700px;
+  width: 50%;
+  z-index: 1;
+  display: flex;
+  align-items: center;
+  margin-top: -6%;
+  padding-bottom: 5%;
+  min-width: 300px;
+  position: relative;
+`;

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

@@ -0,0 +1,29 @@
+import React from "react";
+import { Route, Switch } from "react-router";
+import { Redirect } from "react-router-dom";
+import { OFState } from "./state";
+import ConnectRegistryWrapper from "./steps/ConnectRegistry/ConnectRegistryWrapper";
+import ConnectSource from "./steps/ConnectSource";
+import ProvisionResourcesWrapper from "./steps/ProvisionResources/ProvisionResourcesWrapper";
+
+export const Routes = () => {
+  return (
+    <>
+      <Switch>
+        <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;

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

@@ -0,0 +1,183 @@
+import React, { useMemo, useState } from "react";
+import { integrationList } from "shared/common";
+import styled from "styled-components";
+import { SupportedProviders } from "../types";
+import Selector from "components/Selector";
+
+export type ProviderSelectorProps = {
+  selectProvider: (
+    provider: SupportedProviders | (SupportedProviders | "external")
+  ) => void;
+  options: {
+    value: string;
+    icon: string;
+    label: string;
+  }[];
+};
+
+export const registryOptions = [
+  {
+    value: "skip",
+    label: "Skip / I don't know what this is",
+    icon: "",
+  },
+  {
+    value: "aws",
+    icon: integrationList["ecr"]?.icon,
+    label: "Amazon Elastic Container Registry (ECR)",
+  },
+  {
+    value: "gcp",
+    icon: integrationList["gcr"]?.icon,
+    label: "Google Cloud Registry (GCR)",
+  },
+  {
+    value: "do",
+    icon: integrationList["do"]?.icon,
+    label: "DigitalOcean Container Registry (DOCR)",
+  },
+];
+
+export const provisionerOptions = [
+  {
+    value: "aws",
+    icon: integrationList["aws"]?.icon,
+    label: "Amazon Web Services (AWS)",
+  },
+  {
+    value: "gcp",
+    icon: integrationList["gcp"]?.icon,
+    label: "Google Cloud Platform (GCP)",
+  },
+  {
+    value: "do",
+    icon: integrationList["do"]?.icon,
+    label: "DigitalOcean (DO)",
+  },
+];
+
+export const provisionerOptionsWithExternal = [
+  ...provisionerOptions,
+  {
+    value: "external",
+    icon: integrationList["kubernetes"]?.icon,
+    label: "Link an existing cluster",
+  },
+];
+
+const ProviderSelector: React.FC<ProviderSelectorProps> = ({
+  selectProvider,
+  options,
+}) => {
+  const [provider, setProvider] = useState(() => {
+    if (options.find((o) => o.value === "skip")) {
+      return "skip";
+    }
+    return null;
+  });
+
+  return (
+    <>
+      <Br />
+      <Selector
+        activeValue={provider}
+        options={options}
+        placeholder="Select a cloud provider"
+        setActiveValue={(provider) => {
+          setProvider(provider);
+          selectProvider(provider as SupportedProviders);
+        }}
+        width="100%"
+        height="45px"
+      />
+      <Br />
+    </>
+  );
+};
+
+export default ProviderSelector;
+
+const Br = styled.div`
+  width: 100%;
+  height: 10px;
+`;
+
+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-items: 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;
+`;

+ 109 - 0
dashboard/src/main/home/onboarding/components/RegistryImageList.tsx

@@ -0,0 +1,109 @@
+import React, { useEffect, useState } from "react";
+import Helper from "components/form-components/Helper";
+import api from "shared/api";
+import styled from "styled-components";
+import { integrationList } from "shared/common";
+
+const RegistryImageList: React.FC<{
+  project: {
+    id: number;
+    name: string;
+  };
+  registryType?: string;
+  registry_id: number;
+}> = ({ project, registry_id, registryType }) => {
+  const [imageList, setImageList] = useState([]);
+
+  useEffect(() => {
+    api
+      .getImageRepos(
+        "<token>",
+        {},
+        {
+          project_id: project.id,
+          registry_id,
+        }
+      )
+      .then((res) => {
+        if (!res?.data) {
+          throw new Error("No data found");
+        }
+        console.log(res.data);
+        setImageList(res.data);
+      })
+      .catch(console.error);
+    return () => {};
+  }, []);
+
+  const getIcon = () => {
+    if (registryType) {
+      return integrationList[registryType] && integrationList[registryType].icon;
+    } else {
+      return integrationList["docker"].icon;
+    }
+  }
+
+  return (
+    <>
+      <Helper>Porter was able to successfully connect to your registry:</Helper>
+      <ImageList>
+
+        {
+          imageList.length > 0 ? (
+            imageList.map((data, i) => (
+              <ImageRow isLast={i === imageList.length - 1}>
+                <img src={getIcon()} />
+                {data.uri}
+              </ImageRow>
+            ))
+          ) : (
+            <Placeholder>No container images found.</Placeholder>
+          )
+        }
+      </ImageList>
+      <Br />
+    </>
+  );
+};
+
+export default RegistryImageList;
+
+const Placeholder = styled.div`
+  width: 100%;
+  height: 80px;
+  color: #aaaabb;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+`;
+
+const Br = styled.div`
+  width: 100%;
+  height: 15px;
+`;
+
+const ImageRow = styled.div<{ isLast?: boolean }>`
+  width: 100%;
+  height: 40px;
+  border-bottom: ${props => props.isLast ? "" : "1px solid #aaaabb"};
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  padding: 12px;
+
+  > img {
+    width: 20px;
+    filter: grayscale(100%);
+    margin-right: 9px;
+  }
+`;
+
+const ImageList = styled.div`
+  border-radius: 5px;
+  border: 1px solid #aaaabb;
+  max-height: 300px;
+  overflow-y: auto;
+  background: #ffffff11;
+  margin: 20px 0 20px;
+`;

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

@@ -0,0 +1,131 @@
+import { proxy } from "valtio";
+import type {
+  AWSProvisionerConfig,
+  AWSRegistryConfig,
+  DORegistryConfig,
+  GCPProvisionerConfig,
+  GCPRegistryConfig,
+  SkipProvisionConfig,
+  SkipRegistryConnection,
+} 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 = {
+  user_email: string;
+  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({
+  user_email: null,
+  project: null,
+  connected_source: null,
+  connected_registry: null,
+  provision_resources: null,
+  current_error: 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,
+      };
+    },
+    clearRegistryProvider: () => {
+      StateHandler.connected_registry.provider = "";
+    },
+    clearResourceProvisioningProvider: () => {
+      StateHandler.provision_resources.provider = "";
+    },
+    saveCurrentError: (data: any) => {
+      StateHandler.current_error = data;
+    },
+  },
+});

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

@@ -0,0 +1,297 @@
+import { useEffect } from "react";
+import { useLocation } from "react-router";
+import { useRouting } from "shared/routing";
+import { proxy, useSnapshot } from "valtio";
+import { StepKey, Steps } from "../types";
+
+type Step = {
+  url: string;
+  final?: true;
+  substeps?: {
+    [key in string]: Step;
+  };
+  on?: ActionHandler;
+  execute?: {
+    on: {
+      skip?: string;
+      continue?: string;
+      go_back?: string;
+    };
+  };
+};
+
+export type Action = "skip" | "continue" | "go_back";
+type ActionHandler = {
+  skip?: string;
+  continue: string;
+  go_back?: string;
+};
+
+export type FlowType = {
+  initial: StepKey;
+  steps: {
+    [key in Steps]: Step;
+  };
+};
+
+const flow: FlowType = {
+  initial: "connect_source",
+  steps: {
+    connect_source: {
+      url: "/onboarding/source",
+      on: {
+        continue: "connect_registry",
+      },
+      execute: {
+        on: {
+          continue: "saveSelectedSource",
+        },
+      },
+    },
+    connect_registry: {
+      url: "/onboarding/registry",
+      on: {
+        skip: "provision_resources",
+        continue: "connect_registry.credentials",
+        go_back: "connect_source",
+      },
+      execute: {
+        on: {
+          skip: "skipRegistryConnection",
+          continue: "saveRegistryProvider",
+        },
+      },
+      substeps: {
+        credentials: {
+          url: "/onboarding/registry/credentials",
+          on: {
+            continue: "connect_registry.settings",
+            go_back: "connect_registry",
+          },
+
+          execute: {
+            on: {
+              continue: "saveRegistryCredentials",
+              go_back: "clearRegistryProvider",
+            },
+          },
+        },
+        settings: {
+          url: "/onboarding/registry/settings",
+          on: {
+            continue: "connect_registry.test_connection",
+            go_back: "connect_registry.credentials",
+          },
+
+          execute: {
+            on: {
+              continue: "saveRegistrySettings",
+            },
+          },
+        },
+        test_connection: {
+          url: "/onboarding/registry/test_connection",
+          on: {
+            continue: "provision_resources",
+            /**
+             * Enable this go_back as soon as connect registry
+             * has a proper way of listing the registries and
+             * manage them inside the step
+             */
+            // go_back: "connect_registry",
+          },
+        },
+      },
+    },
+    provision_resources: {
+      url: "/onboarding/provision",
+      on: {
+        skip: "provision_resources.connect_own_cluster",
+        continue: "provision_resources.credentials",
+        /**
+         * Enable this go_back as soon as connect registry
+         * has a proper way of listing the registries and
+         * manage them inside the step
+         */
+        // go_back: "connect_registry",
+      },
+      execute: {
+        on: {
+          skip: "skipResourceProvisioning",
+          continue: "saveResourceProvisioningProvider",
+        },
+      },
+      substeps: {
+        connect_own_cluster: {
+          url: "/onboarding/provision/connect_own_cluster",
+          on: {
+            continue: "clean_up",
+            go_back: "provision_resources",
+          },
+          execute: {
+            on: {
+              go_back: "clearResourceProvisioningProvider",
+            },
+          },
+        },
+        credentials: {
+          url: "/onboarding/provision/credentials",
+          on: {
+            continue: "provision_resources.settings",
+            go_back: "provision_resources",
+          },
+          execute: {
+            on: {
+              continue: "saveResourceProvisioningCredentials",
+              go_back: "clearResourceProvisioningProvider",
+            },
+          },
+        },
+        settings: {
+          url: "/onboarding/provision/settings",
+          on: {
+            continue: "provision_resources.status",
+            go_back: "provision_resources.credentials",
+          },
+          execute: {
+            on: {
+              continue: "saveResourceProvisioningSettings",
+            },
+          },
+        },
+        status: {
+          url: "/onboarding/provision/status",
+          on: {
+            continue: "clean_up",
+            go_back: "provision_resources.credentials",
+          },
+          execute: {
+            on: {
+              go_back: "saveCurrentError",
+            },
+          },
+        },
+      },
+    },
+    clean_up: {
+      final: true,
+      url: "/applications",
+    },
+  },
+};
+
+type StepHandlerType = {
+  flow: FlowType;
+  currentStepName: string;
+  currentStep: Step;
+  canGoBack?: boolean;
+  isSubFlow?: boolean;
+  actions: {
+    nextStep: (action?: Action) => void;
+    clearState: () => void;
+    restoreState: (prevState: Partial<StepHandlerType>) => void;
+    goTo: (step: string) => void;
+    setNewCurrentStep: (stepName: string) => { hasError: boolean };
+  };
+};
+
+export const StepHandler: StepHandlerType = proxy({
+  flow,
+  currentStepName: flow.initial,
+  currentStep: flow.steps[flow.initial],
+  isSubFlow: false,
+  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.actions.setNewCurrentStep(nextStepName);
+      return;
+    },
+    getStep: (nextStepName: string) => {
+      const [stepName, substep] = nextStepName.split(".");
+
+      const step = flow.steps[stepName as Steps];
+
+      let nextStep: Step = step;
+
+      if (substep) {
+        nextStep = step.substeps[substep];
+      }
+      return { step: nextStep, isChild: !!substep };
+    },
+    goTo: (step: string) => {
+      const status = StepHandler.actions.setNewCurrentStep(step);
+      if (status.hasError) {
+        throw new Error(
+          "No next step name found, fix the action triggering nextStep"
+        );
+      }
+    },
+    clearState: () => {
+      StepHandler.actions.setNewCurrentStep(flow.initial);
+    },
+    restoreState: (prevState) => {
+      if (
+        !prevState?.currentStepName ||
+        typeof prevState.currentStepName !== "string"
+      ) {
+        return;
+      }
+      const stepName = prevState.currentStepName;
+
+      StepHandler.actions.setNewCurrentStep(stepName);
+    },
+    setNewCurrentStep: (newStepName: string) => {
+      const [stepName, substep] = newStepName?.split(".");
+
+      const isChild = !!substep;
+      const step = flow.steps[stepName as Steps];
+
+      let nextStep: Step = step;
+
+      if (isChild) {
+        nextStep = step.substeps[substep];
+      }
+
+      if (!nextStep) {
+        return {
+          hasError: true,
+        };
+      }
+
+      StepHandler.currentStepName = newStepName;
+      StepHandler.currentStep = nextStep;
+      StepHandler.canGoBack = !!nextStep?.on?.go_back;
+      StepHandler.isSubFlow = isChild;
+      return {
+        hasError: false,
+      };
+    },
+  },
+});
+
+export const useSteps = (isParentLoading?: boolean) => {
+  const snap = useSnapshot(StepHandler);
+  const location = useLocation();
+  const { pushFiltered } = useRouting();
+  useEffect(() => {
+    if (isParentLoading) {
+      return;
+    }
+    if (snap.currentStepName === "clean_up") {
+      StepHandler.actions.clearState();
+    }
+    pushFiltered(snap.currentStep.url, ["tab"]);
+  }, [location.pathname, snap.currentStep?.url, isParentLoading]);
+};

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

@@ -0,0 +1,152 @@
+import api from "shared/api";
+import { proxy } from "valtio";
+import { CompressedOnboardingState, Onboarding } from "../types";
+import { StateHandler } from "./StateHandler";
+import { Action, StepHandler } from "./StepHandler";
+
+export const OFState = proxy({
+  StateHandler,
+  StepHandler,
+  subscriptions: [],
+  actions: {
+    initializeState: (state: Onboarding) => {
+      OFState.actions.restoreState(state);
+    },
+    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();
+    },
+    goTo: (step: string) => {
+      StepHandler.actions.goTo(step);
+      OFState.actions.saveState();
+    },
+    clearState: () => {
+      StateHandler.actions.clearState();
+      StepHandler.actions.clearState();
+    },
+    saveState: () => {
+      const state = compressState(OFState);
+
+      api
+        .saveOnboardingState(
+          "<token>",
+          {
+            ...state,
+          },
+          { project_id: OFState.StateHandler?.project?.id }
+        )
+        .then((res) => console.log(res))
+        .catch((err) => console.log(err));
+    },
+    restoreState: (state: any) => {
+      const prevState = decompressState(state);
+
+      if (prevState?.StateHandler) {
+        StateHandler.actions.restoreState(prevState.StateHandler);
+      }
+      if (prevState?.StepHandler) {
+        StepHandler.actions.restoreState(prevState.StepHandler);
+      }
+    },
+  },
+});
+
+const compressState = (state: typeof OFState) => {
+  const currentStep = state.StepHandler?.currentStepName;
+  const source = state.StateHandler?.connected_source;
+  const registry = state.StateHandler?.connected_registry;
+  const provision = state.StateHandler?.provision_resources;
+
+  let onboarding_state: CompressedOnboardingState = {
+    current_step: currentStep,
+
+    connected_source: source,
+    skip_registry_connection: registry?.skip,
+
+    registry_connection_provider: registry?.provider,
+    registry_connection_credential_id: registry?.credentials?.id,
+    registry_connection_id: registry?.settings?.registry_connection_id,
+
+    skip_resource_provision: provision?.skip,
+    registry_infra_id: undefined,
+    registry_infra_credential_id: provision?.credentials?.id,
+    registry_infra_provider: provision?.provider,
+
+    cluster_infra_id: undefined,
+    cluster_infra_credential_id: provision?.credentials?.id,
+    cluster_infra_provider: provision?.provider,
+  };
+
+  return onboarding_state;
+};
+
+const decompressState = (prev_state: any) => {
+  const state: any = prev_state;
+
+  const step = state.current_step;
+  const project = {
+    id: state.project_id,
+    name: state.project_name,
+  };
+
+  let registry: any = {
+    skip: state.skip_registry_connection,
+    provider: state.registry_connection_provider,
+    credentials: {
+      id: state?.registry_connection_data?.id,
+    },
+    settings: {
+      registry_connection_id: state?.registry_connection_id,
+      registry_name: state?.registry_connection_data?.name,
+    },
+  };
+
+  if (registry.provider === "gcp") {
+    registry.settings.gcr_url = state.registry_connection_data?.url;
+  } else if (registry.provider === "do") {
+    registry.settings.registry_url = state.registry_connection_data?.url;
+  }
+
+  let provision: any = {
+    skip: state.skip_resource_provision,
+    provider: state.cluster_infra_provider,
+    credentials: {
+      id: state.cluster_infra_credential_id,
+    },
+    settings: {
+      cluster_name: state.resource_provision_settings_cluster_name,
+    },
+  };
+
+  if (provision.provider === "gcp") {
+    provision.credentials.region = state.resource_provision_credentials_region;
+  } else if (provision.provider === "aws") {
+    provision.credentials.region = state.resource_provision_credentials_region;
+    provision.credentials.arn = state.resource_provision_credentials_arn;
+    provision.settings.aws_machine_type =
+      state.resource_provision_settings_machine_type;
+  } else if (provision.provider === "do") {
+    provision.settings.tier = state.resource_provision_settings_tier;
+    provision.settings.region = state.resource_provision_settings_region;
+  }
+
+  return {
+    StepHandler: {
+      currentStepName: step,
+    },
+    StateHandler: {
+      project,
+      connected_source: state.connected_source,
+      connected_registry: registry,
+      provision_resources: provision,
+    },
+  };
+};

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

@@ -0,0 +1,169 @@
+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, {
+  registryOptions,
+} from "../../components/ProviderSelector";
+import { SupportedProviders } from "../../types";
+import backArrow from "assets/back_arrow.png";
+
+import FormFlowWrapper from "./forms/FormFlow";
+
+const ConnectRegistry: React.FC<{
+  provider: SupportedProviders;
+  enable_go_back: boolean;
+  project: {
+    id: number;
+    name: string;
+  };
+  onSelectProvider: (provider: SupportedProviders | "skip") => void;
+  onSaveCredentials: (credentials: any) => void;
+  onSaveSettings: (settings: any) => void;
+  onSuccess: () => void;
+  onSkip: () => void;
+  goBack: () => void;
+}> = ({
+  onSelectProvider,
+  onSaveCredentials,
+  onSaveSettings,
+  onSuccess,
+  onSkip,
+  project,
+  provider,
+  enable_go_back,
+  goBack,
+}) => {
+  const { step } = useParams<any>();
+
+  return (
+    <Div>
+      {enable_go_back && (
+        <BackButton
+          onClick={() => {
+            goBack();
+          }}
+        >
+          <BackButtonImg src={backArrow} />
+        </BackButton>
+      )}
+      <TitleSection>Getting Started</TitleSection>
+      <Subtitle>Step 2 of 3 - Connect an existing registry (Optional)</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>
+
+      {step ? (
+        <FormFlowWrapper
+          provider={provider}
+          onSaveCredentials={onSaveCredentials}
+          onSaveSettings={onSaveSettings}
+          onSuccess={onSuccess}
+          project={project}
+          currentStep={step}
+          goBack={goBack}
+          enable_go_back={enable_go_back}
+        />
+      ) : (
+        <>
+          <ProviderSelector
+            selectProvider={(provider) => {
+              if (provider !== "external") {
+                onSelectProvider(provider);
+              }
+            }}
+            options={registryOptions}
+          />
+          <NextStep
+            text="Continue"
+            disabled={false}
+            onClick={() => onSkip()}
+            status={""}
+            makeFlush={true}
+            clearPosition={true}
+            statusPosition="right"
+            saveText=""
+          />
+        </>
+      )}
+    </Div>
+  );
+};
+
+export default ConnectRegistry;
+
+const Div = styled.div`
+  width: 100%;
+`;
+
+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 ${(props) => props.delay || "1.3s"};
+  animation-fill-mode: forwards;
+
+  @keyframes slideIn {
+    from {
+      opacity: 0;
+      transform: translateX(30px);
+    }
+    to {
+      opacity: 1;
+      transform: translateX(0);
+    }
+  }
+`;
+
+const Subtitle = styled.div`
+  font-size: 16px;
+  font-weight: 500;
+  margin-top: 16px;
+`;
+
+const NextStep = styled(SaveButton)`
+  margin-top: 24px;
+`;
+
+const BackButton = styled.div`
+  margin-bottom: 24px;
+  display: flex;
+  width: 36px;
+  cursor: pointer;
+  height: 36px;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const BackButtonImg = styled.img`
+  width: 16px;
+  opacity: 0.75;
+`;

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

@@ -0,0 +1,25 @@
+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) => {
+        provider !== "skip" && 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")}
+      enable_go_back={snap.StepHandler.canGoBack && !snap.StepHandler.isSubFlow}
+      goBack={() => OFState.actions.nextStep("go_back")}
+    />
+  );
+};
+
+export default ConnectRegistryWrapper;

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

@@ -0,0 +1,180 @@
+import { ConnectedRegistryConfig } from "main/home/onboarding/state/StateHandler";
+import Breadcrumb from "components/Breadcrumb";
+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 { integrationList } from "shared/common";
+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: {
+    label: "Amazon Elastic Container Registry (ECR)",
+    icon: integrationList["ecr"].icon,
+  },
+  gcp: {
+    label: "Google Container Registry (GCR)",
+    icon: integrationList["gcr"].icon,
+  },
+  do: {
+    label: "Digital Ocean Container Registry (DOCR)",
+    icon: integrationList["do"].icon,
+  },
+};
+
+type Props = {
+  provider: SupportedProviders;
+  onSaveCredentials: (credentials: any) => void;
+  onSaveSettings: (settings: any) => void;
+  onSuccess: () => void;
+  project: { id: number; name: string };
+  currentStep: "credentials" | "settings" | "test_connection";
+  goBack: () => void;
+  enable_go_back: boolean;
+};
+
+const FormFlowWrapper: React.FC<Props> = ({
+  onSaveCredentials,
+  onSaveSettings,
+  onSuccess,
+  provider,
+  project,
+  currentStep,
+  goBack,
+  enable_go_back,
+}) => {
+  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 (
+    <FormWrapper>
+      <FormHeader>
+        {currentStep !== "test_connection" && (
+          <CloseButton onClick={() => goBack()}>
+            <i className="material-icons">keyboard_backspace</i>
+          </CloseButton>
+        )}
+        {FormTitle[provider] && <img src={FormTitle[provider].icon} />}
+        {FormTitle[provider] && FormTitle[provider].label}
+      </FormHeader>
+      <Breadcrumb
+        currentStep={currentStep}
+        steps={[
+          { value: "credentials", label: "Credentials" },
+          { value: "settings", label: "Settings" },
+          { value: "test_connection", label: "Test Connection" },
+        ]}
+      />
+      {CurrentForm}
+    </FormWrapper>
+  );
+};
+
+export default FormFlowWrapper;
+
+const CloseButton = styled.div`
+  width: 30px;
+  height: 30px;
+  margin-left: -5px;
+  margin-right: 8px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 1;
+  border-radius: 50%;
+  right: 10px;
+  top: 10px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+
+  > i {
+    font-size: 20px;
+    color: #aaaabb;
+  }
+`;
+
+const FormHeader = styled.div`
+  display: flex;
+  align-items: center;
+  margin-bottom: 15px;
+  font-size: 13px;
+  margin-top: -2px;
+  font-weight: 500;
+
+  > img {
+    height: 22px;
+    margin-right: 12px;
+  }
+`;
+
+const FormWrapper = styled.div`
+  background: #ffffff0a;
+  margin-top: 25px;
+  padding: 20px 20px 23px;
+  border-radius: 5px;
+  position: relative;
+  border: 1px solid #ffffff55;
+`;

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

@@ -0,0 +1,250 @@
+import InputRow from "components/form-components/InputRow";
+import SelectRow from "components/form-components/SelectRow";
+import Helper from "components/form-components/Helper";
+import SaveButton from "components/SaveButton";
+import { AWSRegistryConfig } from "main/home/onboarding/types";
+import React, { useState } from "react";
+import styled from "styled-components";
+import api from "shared/api";
+import { useSnapshot } from "valtio";
+import { OFState } from "../../../state/index";
+import IntegrationCategories from "main/home/integrations/IntegrationCategories";
+import { StateHandler } from "main/home/onboarding/state/StateHandler";
+import RegistryImageList from "main/home/onboarding/components/RegistryImageList";
+
+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;
+    }
+    try {
+      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,
+        },
+      });
+    } catch (error) {
+      setButtonStatus("Something went wrong, please try again");
+    }
+  };
+
+  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%"
+        scrollBuffer={true}
+        value={awsRegion}
+        dropdownMaxHeight="240px"
+        setActiveValue={(x: string) => {
+          setAWSRegion(x);
+        }}
+        label="📍 AWS Region"
+      />
+      <Br />
+      <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;
+    }
+    try {
+      const data = await api
+        .connectECRRegistry(
+          "<token>",
+          {
+            name: registryName,
+            aws_integration_id:
+              snap.StateHandler.connected_registry.credentials.id,
+          },
+          { id: project.id }
+        )
+        .then((res) => res?.data);
+
+      nextFormStep({
+        settings: {
+          registry_connection_id: data?.id,
+          registry_name: registryName,
+        },
+      });
+    } catch (error) {
+      setButtonStatus("Couldn't connect registry.");
+    }
+  };
+
+  return (
+    <>
+      <Helper>Provide a name for Porter to use when displaying your registry.</Helper>
+      <InputRow
+        type="text"
+        value={registryName}
+        setValue={(x) => {
+          setRegistryName(String(x));
+        }}
+        label="🏷️ Registry Name"
+        placeholder="ex: porter-awesome-registry"
+        width="100%"
+      />
+      <Br />
+      <SaveButton
+        text="Connect Registry"
+        disabled={false}
+        onClick={submit}
+        makeFlush={true}
+        clearPosition={true}
+        status={buttonStatus}
+        statusPosition={"right"}
+      />
+    </>
+  );
+};
+
+export const TestRegistryConnection: React.FC<{ nextFormStep: () => void }> = ({
+  nextFormStep,
+}) => {
+  const snap = useSnapshot(StateHandler);
+  console.log(snap.connected_registry.settings);
+  return (
+    <>
+      <RegistryImageList
+        registryType="ecr"
+        project={snap.project}
+        registry_id={snap.connected_registry.settings.registry_connection_id}
+      />
+      <SaveButton
+        text="Continue"
+        disabled={false}
+        onClick={nextFormStep}
+        makeFlush={true}
+        clearPosition={true}
+        status={""}
+        statusPosition={"right"}
+      />
+    </>
+  );
+};
+
+const Br = styled.div`
+  width: 100%;
+  height: 15px;
+`;

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

@@ -0,0 +1,264 @@
+import Helper from "components/form-components/Helper";
+import InputRow from "components/form-components/InputRow";
+import Loading from "components/Loading";
+import SaveButton from "components/SaveButton";
+import RegistryImageList from "main/home/onboarding/components/RegistryImageList";
+import { OFState } from "main/home/onboarding/state";
+import { StateHandler } from "main/home/onboarding/state/StateHandler";
+import { DORegistryConfig } from "main/home/onboarding/types";
+import React, { useEffect, useState } from "react";
+import { useLocation } from "react-router";
+import api from "shared/api";
+import styled from "styled-components";
+import { useSnapshot } from "valtio";
+
+const readableDate = (s: string) => {
+  const ts = new Date(s);
+  const date = ts.toLocaleDateString();
+  const time = ts.toLocaleTimeString([], {
+    hour: "numeric",
+    minute: "2-digit",
+  });
+  return `${time} on ${date}`;
+};
+
+/**
+ * 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 }) => {
+  const snap = useSnapshot(OFState);
+
+  const [isLoading, setIsLoading] = useState(true);
+  const [connectedAccount, setConnectedAccount] = useState(null);
+
+  useEffect(() => {
+    api.getOAuthIds("<token>", {}, { project_id: project?.id }).then((res) => {
+      let integrations = res.data.filter((integration: any) => {
+        return integration.client === "do";
+      });
+
+      if (Array.isArray(integrations) && integrations.length) {
+        // Sort decendant
+        integrations.sort((a, b) => b.id - a.id);
+        let lastUsed = integrations.find((i) => {
+          i.id === snap.StateHandler?.connected_registry?.credentials?.id;
+        });
+        if (!lastUsed) {
+          lastUsed = integrations[0];
+        }
+        setConnectedAccount(lastUsed);
+      }
+      setIsLoading(false);
+    });
+  }, []);
+
+  const submit = (integrationId: number) => {
+    nextFormStep({
+      credentials: {
+        id: integrationId,
+      },
+    });
+  };
+
+  const url = `${window.location.protocol}//${window.location.host}${window.location.pathname}`;
+
+  const encoded_redirect_uri = encodeURIComponent(url);
+
+  if (isLoading) {
+    return <Loading />;
+  }
+
+  return (
+    <>
+      {connectedAccount !== null && (
+        <div>
+          <div>Connected account: {connectedAccount.client}</div>
+          <div>Connected at: {readableDate(connectedAccount.created_at)}</div>
+        </div>
+      )}
+      <ConnectDigitalOceanButton
+        href={`/api/projects/${project?.id}/oauth/digitalocean?redirect_uri=${encoded_redirect_uri}`}
+      >
+        {connectedAccount !== null
+          ? "Connect another account"
+          : "Sign In to Digital Ocean"}
+      </ConnectDigitalOceanButton>
+
+      <Br />
+      {connectedAccount !== null && (
+        <SaveButton
+          text="Continue with connected account"
+          disabled={false}
+          onClick={() => submit(connectedAccount.id)}
+          makeFlush={true}
+          clearPosition={true}
+          status={""}
+          statusPosition={"right"}
+        />
+      )}
+    </>
+  );
+};
+
+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 () => {
+    const data = await api
+      .connectDORegistry(
+        "<token>",
+        {
+          name: registryName,
+          do_integration_id:
+            snap.StateHandler.connected_registry.credentials.id,
+          url: registryUrl,
+        },
+        { project_id: project.id }
+      )
+      .then((res) => res?.data);
+    nextFormStep({
+      settings: {
+        registry_connection_id: data?.id,
+        registry_url: registryUrl,
+      },
+    });
+  };
+
+  return (
+    <>
+      <Helper>
+        Provide a name for Porter to use when displaying your registry.
+      </Helper>
+      <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}
+      />
+      <Br />
+      <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 }) => {
+  const snap = useSnapshot(StateHandler);
+  return (
+    <>
+      <RegistryImageList
+        registryType="docker"
+        project={snap.project}
+        registry_id={snap.connected_registry.settings.registry_connection_id}
+      />
+      <SaveButton
+        text="Continue"
+        disabled={false}
+        onClick={nextFormStep}
+        makeFlush={true}
+        clearPosition={true}
+        status={""}
+        statusPosition={"right"}
+      />
+    </>
+  );
+};
+
+const Br = styled.div`
+  width: 100%;
+  height: 15px;
+`;
+
+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;
+  margin-top: 22px;
+  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;
+  }
+`;

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

@@ -0,0 +1,245 @@
+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 RegistryImageList from "main/home/onboarding/components/RegistryImageList";
+import { OFState } from "main/home/onboarding/state";
+import { StateHandler } from "main/home/onboarding/state/StateHandler";
+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");
+    try {
+      const gcpIntegration = await api
+        .createGCPIntegration(
+          "<token>",
+          {
+            gcp_key_data: serviceAccountKey,
+            gcp_project_id: projectId,
+          },
+          { project_id: project.id }
+        )
+        .then((res) => res.data);
+
+      nextFormStep({
+        credentials: {
+          id: gcpIntegration?.id,
+        },
+      });
+    } catch (error) {
+      setButtonStatus("Something went wrong, please try again");
+    }
+  };
+
+  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}
+      />
+      <Br />
+      <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");
+
+    try {
+      const data = await api
+        .connectGCRRegistry(
+          "<token>",
+          {
+            name: registryName,
+            gcp_integration_id:
+              snap.StateHandler.connected_registry.credentials.id,
+            url: registryUrl,
+          },
+          {
+            id: project.id,
+          }
+        )
+        .then((res) => res?.data);
+
+      nextFormStep({
+        settings: {
+          registry_connection_id: data.id,
+          gcr_url: registryUrl,
+          registry_name: registryName,
+        },
+      });
+    } catch (error) {
+      setButtonStatus("Couldn't connect registry.");
+    }
+  };
+  return (
+    <>
+      <Helper>
+        Provide a name for Porter to use when displaying your registry.
+      </Helper>
+      <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}
+      />
+      <Br />
+      <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 }) => {
+  const snap = useSnapshot(StateHandler);
+  return (
+    <>
+      <RegistryImageList
+        project={snap.project}
+        registry_id={snap.connected_registry.settings.registry_connection_id}
+      />
+      <SaveButton
+        text="Continue"
+        disabled={false}
+        onClick={nextFormStep}
+        makeFlush={true}
+        clearPosition={true}
+        status={""}
+        statusPosition={"right"}
+      />
+    </>
+  );
+};
+
+const Br = styled.div`
+  width: 100%;
+  height: 15px;
+`;
+
+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;
+`;

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

@@ -0,0 +1,271 @@
+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";
+import github from "assets/github.png";
+
+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);
+  };
+
+  const url = `${window.location.protocol}//${window.location.host}${window.location.pathname}`;
+
+  const encoded_redirect_uri = encodeURIComponent(url);
+
+  return (
+    <div>
+      <TitleSection>Getting Started</TitleSection>
+      <Subtitle>Step 1 of 3 - Connect to GitHub</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/install?redirect_uri=${encoded_redirect_uri}`}
+          >
+            <GitHubIcon src={github} /> 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 && (
+        <>
+          <Helper>Porter currently has access to:</Helper>
+          <List>
+            {accountData?.accounts.length > 0 ? (
+              accountData?.accounts.map((name, i) => {
+                return (
+                  <Row
+                    key={i}
+                    isLastItem={i === accountData.accounts.length - 1}
+                  >
+                    <i className="material-icons">bookmark</i>
+                    {name}
+                  </Row>
+                );
+              })
+            ) : (
+              <Placeholder>No repositories found.</Placeholder>
+            )}
+          </List>
+          <Helper>
+            Don't see the right repos?{" "}
+            <A
+              href={`/api/integrations/github-app/install?redirect_uri=${encoded_redirect_uri}`}
+            >
+              Install Porter in more repositories
+            </A>
+            .
+          </Helper>
+          <NextStep
+            text="Continue"
+            disabled={false}
+            onClick={() => nextStep("github")}
+            status={""}
+            makeFlush={true}
+            clearPosition={true}
+            statusPosition="right"
+            saveText=""
+            successText="Project created successfully!"
+          />
+        </>
+      )}
+    </div>
+  );
+};
+
+export default ConnectSource;
+
+const Placeholder = styled.div`
+  width: 100%;
+  height: 80px;
+  color: #aaaabb;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+`;
+
+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 ${(props) => props.delay || "1.3s"};
+  animation-fill-mode: forwards;
+
+  @keyframes slideIn {
+    from {
+      opacity: 0;
+      transform: translateX(30px);
+    }
+    to {
+      opacity: 1;
+      transform: translateX(0);
+    }
+  }
+`;
+
+const GitHubIcon = styled.img`
+  width: 20px;
+  filter: brightness(150%);
+  margin-right: 10px;
+`;
+
+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 #aaaabb;
+  max-height: 200px;
+  overflow-y: auto;
+`;
+
+const Row = styled.div<{ isLastItem?: boolean }>`
+  width: 100%;
+  height: 35px;
+  color: #ffffff55;
+  font-size: 13px;
+  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.div`
+  font-size: 16px;
+  font-weight: 500;
+  margin-top: 16px;
+`;
+
+const ConnectToGithubButton = styled.a`
+  width: 180px;
+  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;
+  margin-top: 25px;
+  margin-bottom: 25px;
+  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" : "#2E3338"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#353a3e"};
+  }
+
+  > 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;
+  }
+`;

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

@@ -0,0 +1,214 @@
+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, {
+  provisionerOptions,
+  provisionerOptionsWithExternal,
+} from "../../components/ProviderSelector";
+
+import FormFlowWrapper from "./forms/FormFlow";
+import ConnectExternalCluster from "./forms/_ConnectExternalCluster";
+import { SupportedProviders } from "../../types";
+import backArrow from "assets/back_arrow.png";
+import { SharedStatus } from "./forms/SharedStatus";
+
+type Props = {
+  provider: SupportedProviders | "external";
+  enable_go_back: boolean;
+  project: {
+    id: number;
+    name: string;
+  };
+  shouldProvisionRegistry: boolean;
+  onSelectProvider: (provider: SupportedProviders | "external") => void;
+  onSaveCredentials: (credentials: any) => void;
+  onSaveSettings: (settings: any) => void;
+  onSuccess: () => void;
+  onSkip: () => void;
+  goBack: (data?: any) => void;
+};
+
+const ProvisionResources: React.FC<Props> = ({
+  provider,
+  project,
+  shouldProvisionRegistry,
+  onSelectProvider,
+  onSaveCredentials,
+  onSaveSettings,
+  onSuccess,
+
+  enable_go_back,
+  goBack,
+}) => {
+  const { step } = useParams<{ step: any }>();
+  const [infraStatus, setInfraStatus] = useState<{
+    hasError: boolean;
+    description?: string;
+  }>(null);
+
+  const renderSaveButton = () => {
+    if (infraStatus && !infraStatus.hasError) {
+      return (
+        <>
+          <Br height="15px" />
+          <SaveButton
+            text="Continue"
+            disabled={false}
+            onClick={onSuccess}
+            makeFlush={true}
+            clearPosition={true}
+            statusPosition="right"
+            saveText=""
+          />
+        </>
+      );
+    } else if (infraStatus) {
+      return (
+        <>
+          <Br height="15px" />
+          <SaveButton
+            text="Resolve Errors"
+            status="Encountered errors while provisioning."
+            disabled={false}
+            onClick={() => goBack(infraStatus.description)}
+            makeFlush={true}
+            clearPosition={true}
+            statusPosition="right"
+            saveText=""
+          />
+        </>
+      );
+    }
+  };
+
+  const getFilterOpts = (): string[] => {
+    switch (provider) {
+      case "aws":
+        return ["eks", "ecr"];
+      case "gcp":
+        return ["gke", "gcr"];
+      case "do":
+        return ["doks", "docr"];
+    }
+
+    return [];
+  };
+
+  const Content = () => {
+    switch (step) {
+      case "credentials":
+      case "settings":
+        return (
+          <FormFlowWrapper
+            provider={provider}
+            currentStep={step}
+            onSaveCredentials={onSaveCredentials}
+            onSaveSettings={onSaveSettings}
+            project={project}
+            goBack={goBack}
+          />
+        );
+      case "status":
+        return (
+          <>
+            <SharedStatus
+              project_id={project?.id}
+              filter={getFilterOpts()}
+              setInfraStatus={setInfraStatus}
+            />
+            <Br />
+            <Helper>Note: Provisioning can take up to 15 minutes.</Helper>
+            {renderSaveButton()}
+          </>
+        );
+      case "connect_own_cluster":
+        return (
+          <ConnectExternalCluster
+            nextStep={onSuccess}
+            project={project}
+            goBack={goBack}
+          />
+        );
+      default:
+        return (
+          <ProviderSelector
+            selectProvider={(provider) => {
+              onSelectProvider(provider);
+            }}
+            options={
+              shouldProvisionRegistry
+                ? provisionerOptions
+                : provisionerOptionsWithExternal
+            }
+          />
+        );
+    }
+  };
+
+  return (
+    <div>
+      {enable_go_back && (
+        <BackButton
+          onClick={() => {
+            goBack();
+          }}
+        >
+          <BackButtonImg src={backArrow} />
+        </BackButton>
+      )}
+      <TitleSection>Getting Started</TitleSection>
+      <Subtitle>Step 3 of 3 - Provision resources</Subtitle>
+      <Helper>
+        Porter automatically creates a cluster and registry in your cloud to run
+        applications.
+      </Helper>
+      {Content()}
+    </div>
+  );
+};
+
+export default ProvisionResources;
+
+const Br = styled.div<{ height?: string }>`
+  width: 100%;
+  height: ${(props) => props.height || "1px"};
+  margin-top: -3px;
+`;
+
+const Subtitle = styled.div`
+  font-size: 16px;
+  font-weight: 500;
+  margin-top: 16px;
+`;
+
+const NextStep = styled(SaveButton)`
+  margin-top: 24px;
+`;
+
+const BackButton = styled.div`
+  margin-bottom: 24px;
+  display: flex;
+  width: 36px;
+  cursor: pointer;
+  height: 36px;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const BackButtonImg = styled.img`
+  width: 16px;
+  opacity: 0.75;
+`;

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

@@ -0,0 +1,30 @@
+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: string) => {
+        if (provider !== "external") {
+          OFState.actions.nextStep("continue", provider);
+          return;
+        }
+        OFState.actions.nextStep("skip");
+      }}
+      onSaveCredentials={(data) => OFState.actions.nextStep("continue", data)}
+      onSaveSettings={(data) => OFState.actions.nextStep("continue", data)}
+      onSuccess={() => OFState.actions.nextStep("continue")}
+      onSkip={() => OFState.actions.nextStep("skip")}
+      enable_go_back={snap.StepHandler.canGoBack && !snap.StepHandler.isSubFlow}
+      goBack={(data: any) => OFState.actions.nextStep("go_back", data)}
+    />
+  );
+};
+
+export default ProvisionResourcesWrapper;

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

@@ -0,0 +1,172 @@
+import { ProvisionerConfig } from "main/home/onboarding/state/StateHandler";
+import {
+  SkipProvisionConfig,
+  SupportedProviders,
+} from "main/home/onboarding/types";
+import React, { useMemo } from "react";
+import styled from "styled-components";
+import Breadcrumb from "components/Breadcrumb";
+import { integrationList } from "shared/common";
+import {
+  CredentialsForm as AWSCredentialsForm,
+  SettingsForm as AWSSettingsForm,
+} from "./_AWSProvisionerForm";
+
+import {
+  CredentialsForm as DOCredentialsForm,
+  SettingsForm as DOSettingsForm,
+} from "./_DOProvisionerForm";
+
+import {
+  CredentialsForm as GCPCredentialsForm,
+  SettingsForm as GCPSettingsForm,
+} from "./_GCPProvisionerForm";
+
+const Forms = {
+  aws: {
+    credentials: AWSCredentialsForm,
+    settings: AWSSettingsForm,
+  },
+  gcp: {
+    credentials: GCPCredentialsForm,
+    settings: GCPSettingsForm,
+  },
+  do: {
+    credentials: DOCredentialsForm,
+    settings: DOSettingsForm,
+  },
+};
+
+const FormTitle = {
+  aws: {
+    label: "Amazon Web Services (AWS)",
+    icon: integrationList["aws"].icon,
+  },
+  gcp: {
+    label: "Google Cloud Platform (GCP)",
+    icon: integrationList["gcp"].icon,
+  },
+  do: {
+    label: "Digital Ocean (DO)",
+    icon: integrationList["do"].icon,
+  },
+  external: {
+    label: "Connect an existing cluster",
+    icon: integrationList["kubernetes"],
+  },
+};
+
+type Props = {
+  onSaveCredentials: (credentials: any) => void;
+  onSaveSettings: (settings: any) => void;
+  provider: SupportedProviders | "external";
+  currentStep: "credentials" | "settings";
+  project: { id: number; name: string };
+  goBack: () => void;
+};
+
+const FormFlowWrapper: React.FC<Props> = ({
+  onSaveCredentials,
+  onSaveSettings,
+  provider,
+  currentStep,
+  project,
+  goBack,
+}) => {
+  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 (
+    <FormWrapper>
+      <FormHeader>
+        <CloseButton onClick={() => goBack()}>
+          <i className="material-icons">keyboard_backspace</i>
+        </CloseButton>
+        {FormTitle[provider] && <img src={FormTitle[provider].icon} />}
+        {FormTitle[provider] && FormTitle[provider].label}
+      </FormHeader>
+      <Breadcrumb
+        currentStep={currentStep}
+        steps={[
+          { value: "credentials", label: "Credentials" },
+          { value: "settings", label: "Settings" },
+        ]}
+      />
+      {CurrentForm}
+    </FormWrapper>
+  );
+};
+
+export default FormFlowWrapper;
+
+const CloseButton = styled.div`
+  width: 30px;
+  height: 30px;
+  margin-left: -5px;
+  margin-right: 8px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 1;
+  border-radius: 50%;
+  right: 10px;
+  top: 10px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+
+  > i {
+    font-size: 20px;
+    color: #aaaabb;
+  }
+`;
+
+const FormHeader = styled.div`
+  display: flex;
+  align-items: center;
+  margin-bottom: 15px;
+  font-size: 13px;
+  margin-top: -2px;
+  font-weight: 500;
+
+  > img {
+    height: 18px;
+    margin-right: 12px;
+  }
+`;
+
+const FormWrapper = styled.div`
+  background: #ffffff0a;
+  margin-top: 25px;
+  padding: 20px 20px 23px;
+  border-radius: 5px;
+  position: relative;
+  border: 1px solid #ffffff55;
+`;

+ 353 - 0
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/SharedStatus.tsx

@@ -0,0 +1,353 @@
+import ProvisionerStatus, {
+  TFModule,
+  TFResource,
+  TFResourceError,
+} from "components/ProvisionerStatus";
+import React, { useEffect, useState } from "react";
+import api from "shared/api";
+import { useWebsockets } from "shared/hooks/useWebsockets";
+
+export const SharedStatus: React.FC<{
+  setInfraStatus: (status: { hasError: boolean; description?: string }) => void;
+  project_id: number;
+  filter: string[];
+}> = ({ setInfraStatus, project_id, filter }) => {
+  const {
+    newWebsocket,
+    openWebsocket,
+    closeWebsocket,
+    closeAllWebsockets,
+  } = useWebsockets();
+
+  const [tfModules, setTFModules] = useState<TFModule[]>([]);
+
+  const updateTFModules = (
+    index: number,
+    addedResources: TFResource[],
+    erroredResources: TFResource[],
+    globalErrors: TFResourceError[],
+    gotDesired?: boolean
+  ) => {
+    if (!tfModules[index]?.resources) {
+      tfModules[index].resources = [];
+    }
+
+    if (!tfModules[index]?.global_errors) {
+      tfModules[index].global_errors = [];
+    }
+
+    if (gotDesired) {
+      tfModules[index].got_desired = true;
+    }
+
+    let resources = tfModules[index].resources;
+
+    // construct map of tf resources addresses to indices
+    let resourceAddrMap = new Map<string, number>();
+
+    tfModules[index].resources.forEach((resource, index) => {
+      resourceAddrMap.set(resource.addr, index);
+    });
+
+    for (let addedResource of addedResources) {
+      // if exists, update state to provisioned
+      if (resourceAddrMap.has(addedResource.addr)) {
+        let currResource = resources[resourceAddrMap.get(addedResource.addr)];
+        addedResource.errored = currResource.errored;
+        resources[resourceAddrMap.get(addedResource.addr)] = addedResource;
+      } else {
+        resources.push(addedResource);
+        resourceAddrMap.set(addedResource.addr, resources.length - 1);
+
+        // if the resource is being added but there's not a desired state, re-query for the
+        // desired state
+        if (!tfModules[index].got_desired) {
+          updateDesiredState(index, tfModules[index]);
+        }
+      }
+    }
+
+    for (let erroredResource of erroredResources) {
+      // if exists, update state to provisioned
+      if (resourceAddrMap.has(erroredResource.addr)) {
+        resources[resourceAddrMap.get(erroredResource.addr)] = erroredResource;
+      } else {
+        resources.push(erroredResource);
+        resourceAddrMap.set(erroredResource.addr, resources.length - 1);
+      }
+    }
+
+    tfModules[index].global_errors = [
+      ...tfModules[index].global_errors,
+      ...globalErrors,
+    ];
+
+    // remove duplicate global errors
+    tfModules[index].global_errors = tfModules[index].global_errors.filter(
+      (error, index, self) =>
+        index === self.findIndex((e) => e.error_context === error.error_context)
+    );
+
+    setTFModules([...tfModules]);
+  };
+
+  useEffect(() => {
+    // recompute tf module state each time, to see if infra is ready
+    if (tfModules.length > 0) {
+      // see if all tf modules are in a "created" state
+      if (
+        tfModules.filter((val) => val.status == "created").length ==
+        tfModules.length
+      ) {
+        setInfraStatus({
+          hasError: false,
+        });
+        return;
+      }
+
+      if (
+        tfModules.filter((val) => val.status == "error").length ==
+        tfModules.length
+      ) {
+        setInfraStatus({
+          hasError: true,
+        });
+        return;
+      }
+
+      // otherwise, check that all resources in each module are provisioned. Each module
+      // must have more than one resource
+      let numModulesSuccessful = 0;
+      let numModulesErrored = 0;
+
+      for (let tfModule of tfModules) {
+        if (tfModule.status == "created") {
+          numModulesSuccessful++;
+        } else if (tfModule.status == "error") {
+          numModulesErrored++;
+        } else {
+          let resLength = tfModule.resources?.length;
+          if (resLength > 0) {
+            numModulesSuccessful +=
+              tfModule.resources.filter((resource) => resource.provisioned)
+                .length == resLength
+                ? 1
+                : 0;
+
+            // if there's a global error, or the number of resources that errored_out is
+            // greater than 0, this resource is in an error state
+            numModulesErrored +=
+              tfModule.global_errors?.length > 0 ||
+              tfModule.resources.filter(
+                (resource) => resource.errored?.errored_out
+              ).length > 0
+                ? 1
+                : 0;
+          } else if (tfModule.global_errors?.length > 0) {
+            numModulesErrored += 1;
+          }
+        }
+      }
+
+      if (numModulesSuccessful == tfModules.length) {
+        setInfraStatus({
+          hasError: false,
+        });
+      } else if (numModulesErrored + numModulesSuccessful == tfModules.length) {
+        // otherwise, if all modules are either in an error state or successful,
+        // set the status to error
+        setInfraStatus({
+          hasError: true,
+        });
+      }
+    } else {
+      setInfraStatus(null);
+    }
+  }, [tfModules]);
+
+  const setupInfraWebsocket = (
+    websocketID: string,
+    module: TFModule,
+    index: number
+  ) => {
+    let apiPath = `/api/projects/${project_id}/infras/${module.id}/logs`;
+
+    const wsConfig = {
+      onopen: () => {
+        console.log(`connected to websocket: ${websocketID}`);
+      },
+      onmessage: (evt: MessageEvent) => {
+        // parse the data
+        let parsedData = JSON.parse(evt.data);
+
+        let addedResources: TFResource[] = [];
+        let erroredResources: TFResource[] = [];
+        let globalErrors: TFResourceError[] = [];
+
+        for (let streamVal of parsedData) {
+          let streamValData = JSON.parse(streamVal?.Values?.data);
+
+          switch (streamValData?.type) {
+            case "apply_complete":
+              addedResources.push({
+                addr: streamValData?.hook?.resource?.addr,
+                provisioned: true,
+                errored: {
+                  errored_out: false,
+                },
+              });
+
+              break;
+            case "diagnostic":
+              if (streamValData["@level"] == "error") {
+                if (streamValData?.hook?.resource?.addr != "") {
+                  erroredResources.push({
+                    addr: streamValData?.hook?.resource?.addr,
+                    provisioned: false,
+                    errored: {
+                      errored_out: true,
+                      error_context: streamValData["@message"],
+                    },
+                  });
+                } else {
+                  globalErrors.push({
+                    errored_out: true,
+                    error_context: streamValData["@message"],
+                  });
+                }
+              }
+            case "change_summary":
+              if (streamValData.changes.add != 0) {
+                updateDesiredState(index, module);
+              }
+            default:
+          }
+        }
+
+        updateTFModules(index, addedResources, erroredResources, globalErrors);
+      },
+
+      onclose: () => {
+        console.log(`closing websocket: ${websocketID}`);
+      },
+
+      onerror: (err: ErrorEvent) => {
+        console.log(err);
+        closeWebsocket(websocketID);
+      },
+    };
+
+    newWebsocket(websocketID, apiPath, wsConfig);
+    openWebsocket(websocketID);
+  };
+
+  const mergeCurrentAndDesired = (
+    index: number,
+    desired: any,
+    currentMap: Map<string, string>
+  ) => {
+    // map desired state to list of resources
+    var addedResources: TFResource[] = desired?.map((val: any) => {
+      return {
+        addr: val?.addr,
+        provisioned: currentMap.has(val?.addr),
+        errored: {
+          errored_out: val?.errored?.errored_out,
+          error_context: val?.errored?.error_context,
+        },
+      };
+    });
+
+    updateTFModules(index, addedResources, [], [], true);
+  };
+
+  const updateDesiredState = (index: number, val: TFModule) => {
+    api
+      .getInfraDesired(
+        "<token>",
+        {},
+        { project_id: project_id, infra_id: val?.id }
+      )
+      .then((resDesired) => {
+        api
+          .getInfraCurrent(
+            "<token>",
+            {},
+            { project_id: project_id, infra_id: val?.id }
+          )
+          .then((resCurrent) => {
+            var desired = resDesired.data;
+            var current = resCurrent.data;
+
+            // convert current state to a lookup table
+            var currentMap: Map<string, string> = new Map();
+
+            current?.resources?.forEach((val: any) => {
+              currentMap.set(val?.type + "." + val?.name, "");
+            });
+
+            mergeCurrentAndDesired(index, desired, currentMap);
+          })
+          .catch((err) => {
+            var desired = resDesired.data;
+            var currentMap: Map<string, string> = new Map();
+
+            // merge with empty current map
+            mergeCurrentAndDesired(index, desired, currentMap);
+          });
+      })
+      .catch((err) => console.log(err));
+  };
+
+  useEffect(() => {
+    api.getInfra("<token>", {}, { project_id: project_id }).then((res) => {
+      var matchedInfras: Map<string, any> = new Map();
+
+      res.data.forEach((infra: any) => {
+        // if filter list is empty, add infra automatically
+        if (filter.length == 0) {
+          matchedInfras.set(infra.kind + "-" + infra.id, infra);
+        } else if (
+          (filter.includes(infra.kind) && matchedInfras.get(infra.Kind)?.id) ||
+          0 < infra.id
+        ) {
+          matchedInfras.set(infra.kind, infra);
+        }
+      });
+
+      // query for desired and current state, and convert to tf module
+      matchedInfras.forEach((infra: any) => {
+        var module: TFModule = {
+          id: infra.id,
+          kind: infra.kind,
+          status: infra.status,
+          got_desired: false,
+          created_at: infra.created_at,
+        };
+
+        tfModules.push(module);
+      });
+
+      setTFModules([...tfModules]);
+
+      tfModules.forEach((val, index) => {
+        if (val?.status != "created" && val?.status != "destroyed") {
+          updateDesiredState(index, val);
+          setupInfraWebsocket(val.id + "", val, index);
+        }
+      });
+    });
+
+    return closeAllWebsockets;
+  }, []);
+
+  let sortedModules = tfModules.sort((a, b) =>
+    b.id < a.id ? -1 : b.id > a.id ? 1 : 0
+  );
+
+  return (
+    <>
+      <ProvisionerStatus modules={sortedModules} />
+    </>
+  );
+};

+ 317 - 0
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_AWSProvisionerForm.tsx

@@ -0,0 +1,317 @@
+import styled from "styled-components";
+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 { useSnapshot } from "valtio";
+import { SharedStatus } from "./SharedStatus";
+
+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;
+    }
+
+    try {
+      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,
+        },
+      });
+    } catch (error) {
+      setButtonStatus("Something went wrong, please try again");
+    }
+  };
+
+  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}
+        scrollBuffer={true}
+        dropdownMaxHeight="240px"
+        setActiveValue={(x: string) => {
+          setAWSRegion(x);
+        }}
+        label="📍 AWS Region"
+      />
+      <Br />
+      <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(`${project.name}-cluster`);
+  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 hasRegistryProvisioned = (
+    infras: { kind: string; status: string }[]
+  ) => {
+    return !!infras.find(
+      (i) => ["docr", "gcr", "ecr"].includes(i.kind) && i.status === "created"
+    );
+  };
+
+  const hasClusterProvisioned = (
+    infras: { kind: string; status: string }[]
+  ) => {
+    return !!infras.find(
+      (i) => ["doks", "gks", "eks"].includes(i.kind) && i.status === "created"
+    );
+  };
+
+  const provisionECR = async (awsIntegrationId: number) => {
+    console.log("Started provision ECR");
+
+    try {
+      return await api
+        .provisionECR(
+          "<token>",
+          {
+            aws_integration_id: awsIntegrationId,
+            ecr_name: `${project.name}-registry`,
+          },
+          { id: project.id }
+        )
+        .then((res) => res?.data);
+    } catch (error) {
+      catchError(error);
+    }
+  };
+
+  const provisionEKS = async (awsIntegrationId: number) => {
+    try {
+      return await api
+        .provisionEKS(
+          "<token>",
+          {
+            aws_integration_id: awsIntegrationId,
+            eks_name: clusterName,
+            machine_type: machineType,
+            issuer_email: snap.StateHandler.user_email,
+          },
+          { id: project.id }
+        )
+        .then((res) => res?.data);
+    } catch (error) {
+      catchError(error);
+    }
+  };
+
+  const submit = async () => {
+    const validation = validate();
+    if (validation.hasError) {
+      setButtonStatus(validation.error);
+      return;
+    }
+
+    let infras = [];
+
+    try {
+      infras = await api
+        .getInfra("<token>", {}, { project_id: project?.id })
+        .then((res) => res?.data);
+    } catch (error) {
+      setButtonStatus("Something went wrong, try again later");
+      return;
+    }
+
+    const integrationId = snap.StateHandler.provision_resources.credentials.id;
+
+    let registryProvisionResponse = null;
+    let clusterProvisionResponse = null;
+
+    const shouldProvisionECR = snap.StateHandler.connected_registry.skip;
+
+    if (shouldProvisionECR) {
+      if (!hasRegistryProvisioned(infras)) {
+        registryProvisionResponse = await provisionECR(integrationId);
+      }
+    }
+    if (!hasClusterProvisioned(infras)) {
+      clusterProvisionResponse = await provisionEKS(integrationId);
+    }
+
+    nextFormStep({
+      settings: {
+        registry_infra_id: registryProvisionResponse?.id,
+        cluster_infra_id: clusterProvisionResponse?.id,
+        cluster_name: clusterName,
+        aws_machine_type: machineType,
+      },
+    });
+  };
+
+  return (
+    <>
+      <InputRow
+        type="text"
+        value={clusterName}
+        setValue={(x) => {
+          setClusterName(String(x));
+        }}
+        label="Cluster Name"
+        placeholder="ex: porter-cluster"
+        width="100%"
+      />
+      <SelectRow
+        options={machineTypeOptions}
+        width="100%"
+        value={machineType}
+        dropdownMaxHeight="240px"
+        setActiveValue={(x: string) => {
+          setMachineType(x);
+        }}
+        label="⚙️ AWS Machine Type"
+      />
+      <Br />
+      <SaveButton
+        text="Provision resources"
+        disabled={false}
+        onClick={submit}
+        makeFlush={true}
+        clearPosition={true}
+        status={buttonStatus}
+        statusPosition={"right"}
+      />
+    </>
+  );
+};
+
+const Br = styled.div`
+  width: 100%;
+  height: 15px;
+`;

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

@@ -0,0 +1,327 @@
+import React, { useEffect, useState } from "react";
+import styled from "styled-components";
+import TabSelector from "components/TabSelector";
+import api from "shared/api";
+import SaveButton from "components/SaveButton";
+import { integrationList } from "shared/common";
+
+type Props = {
+  nextStep: () => void;
+  project: {
+    id: number;
+    name: string;
+  };
+  goBack: () => void;
+};
+
+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, goBack }) => {
+  const [currentPage, setCurrentPage] = useState(0);
+  const [currentTab, setCurrentTab] = useState("mac");
+  const [enableContinue, setEnableContinue] = useState(false);
+
+  const getClusters = async (
+    status: { isSubscribed: boolean },
+    retryCount = 0
+  ) => {
+    try {
+      api.getClusters("<token>", {}, { id: project.id }).then((res) => {
+        if (Array.isArray(res.data) && res.data.length > 0) {
+          if (status.isSubscribed) {
+            setEnableContinue(true);
+          }
+        } else {
+          if (status.isSubscribed) {
+            setTimeout(() => {
+              getClusters(status, retryCount + 1);
+            }, 1000);
+          }
+        }
+      });
+    } catch (error) {}
+  };
+
+  useEffect(() => {
+    let status = { isSubscribed: true };
+    getClusters(status);
+    return () => {
+      status.isSubscribed = false;
+    };
+  }, []);
+
+  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>
+          </Placeholder>
+        );
+      case 1:
+        return (
+          <Placeholder>
+            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 2:
+        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 (
+    <Wrapper>
+      <StyledClusterInstructionsModal>
+        <FormHeader>
+          <CloseButton onClick={() => goBack()}>
+            <i className="material-icons">keyboard_backspace</i>
+          </CloseButton>
+          <img src={integrationList["kubernetes"].icon} />
+          Link an existing cluster
+        </FormHeader>
+        <TabSelector
+          options={tabOptions}
+          currentTab={currentTab}
+          setCurrentTab={(value: string) => setCurrentTab(value)}
+        />
+
+        {renderPage()}
+        <PageSection>
+          <PageCount>{currentPage + 1}/3</PageCount>
+          <i
+            className="material-icons"
+            onClick={() =>
+              currentPage > 0 ? setCurrentPage(currentPage - 1) : null
+            }
+          >
+            arrow_back
+          </i>
+          <i
+            className="material-icons"
+            onClick={() =>
+              currentPage < 2 ? setCurrentPage(currentPage + 1) : null
+            }
+          >
+            arrow_forward
+          </i>
+        </PageSection>
+      </StyledClusterInstructionsModal>
+      <NextStep
+        text="Continue"
+        disabled={!enableContinue}
+        onClick={() => nextStep()}
+        status={!enableContinue ? "No connected cluster detected" : "successful"}
+        makeFlush={true}
+        clearPosition={true}
+        statusPosition="right"
+        saveText=""
+      />
+    </Wrapper>
+  );
+};
+
+export default ConnectExternalCluster;
+
+const Wrapper = styled.div`
+`;
+
+const CloseButton = styled.div`
+  width: 30px;
+  height: 30px;
+  margin-left: -5px;
+  margin-right: 8px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 1;
+  border-radius: 50%;
+  right: 10px;
+  top: 10px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+
+  > i {
+    font-size: 20px;
+    color: #aaaabb;
+  }
+`;
+
+const FormHeader = styled.div`
+  display: flex;
+  align-items: center;
+  margin-bottom: 15px;
+  font-size: 13px;
+  margin-top: -2px;
+  font-weight: 500;
+
+  > img {
+    height: 18px;
+    margin-right: 12px;
+  }
+`;
+
+const NextStep = styled(SaveButton)`
+  margin-top: 25px;
+`;
+
+const PageCount = styled.div`
+  margin-right: 9px;
+  user-select: none;
+  letter-spacing: 2px;
+`;
+
+const PageSection = styled.div`
+  position: absolute;
+  bottom: 12px;
+  right: 15px;
+  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;
+  width: 550px;
+  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 StyledClusterInstructionsModal = styled.div`
+  width: 100%;
+  padding: 20px 20px 35px;
+  border-radius: 5px;
+  overflow: hidden;
+  position: relative;
+  background: #ffffff0a;
+  border: 1px solid #ffffff55;
+`;

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

@@ -0,0 +1,378 @@
+import Helper from "components/form-components/Helper";
+import InputRow from "components/form-components/InputRow";
+import SelectRow from "components/form-components/SelectRow";
+import ProvisionerStatus, {
+  TFModule,
+  TFResource,
+} from "components/ProvisionerStatus";
+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";
+import { useWebsockets } from "shared/hooks/useWebsockets";
+import { SharedStatus } from "./SharedStatus";
+import Loading from "components/Loading";
+
+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" },
+];
+
+const readableDate = (s: string) => {
+  const ts = new Date(s);
+  const date = ts.toLocaleDateString();
+  const time = ts.toLocaleTimeString([], {
+    hour: "numeric",
+    minute: "2-digit",
+  });
+  return `${time} on ${date}`;
+};
+
+/**
+ * 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 }) => {
+  const snap = useSnapshot(OFState);
+
+  const [isLoading, setIsLoading] = useState(true);
+  const [connectedAccount, setConnectedAccount] = useState(null);
+
+  useEffect(() => {
+    api.getOAuthIds("<token>", {}, { project_id: project?.id }).then((res) => {
+      let integrations = res.data.filter((integration: any) => {
+        return integration.client === "do";
+      });
+
+      if (Array.isArray(integrations) && integrations.length) {
+        // Sort decendant
+        integrations.sort((a, b) => b.id - a.id);
+        let lastUsed = integrations.find((i) => {
+          i.id === snap.StateHandler?.provision_resources?.credentials?.id;
+        });
+        if (!lastUsed) {
+          lastUsed = integrations[0];
+        }
+        setConnectedAccount(lastUsed);
+      }
+      setIsLoading(false);
+    });
+  }, []);
+
+  const submit = (integrationId: number) => {
+    nextFormStep({
+      credentials: {
+        id: integrationId,
+      },
+    });
+  };
+
+  const url = `${window.location.protocol}//${window.location.host}${window.location.pathname}`;
+
+  const encoded_redirect_uri = encodeURIComponent(url);
+
+  if (isLoading) {
+    return <Loading />;
+  }
+
+  return (
+    <>
+      {connectedAccount !== null && (
+        <div>
+          <div>Connected account: {connectedAccount.client}</div>
+          <div>Connected at: {readableDate(connectedAccount.created_at)}</div>
+        </div>
+      )}
+      <ConnectDigitalOceanButton
+        href={`/api/projects/${project?.id}/oauth/digitalocean?redirect_uri=${encoded_redirect_uri}`}
+      >
+        {connectedAccount !== null
+          ? "Connect another account"
+          : "Sign In to Digital Ocean"}
+      </ConnectDigitalOceanButton>
+
+      <Br />
+      {connectedAccount !== null && (
+        <SaveButton
+          text="Continue with connected account"
+          disabled={false}
+          onClick={() => submit(connectedAccount.id)}
+          makeFlush={true}
+          clearPosition={true}
+          status={""}
+          statusPosition={"right"}
+        />
+      )}
+    </>
+  );
+};
+
+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(`${project.name}-cluster`);
+
+  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 catchError = (error: any) => {
+    console.error(error);
+  };
+
+  const hasRegistryProvisioned = (
+    infras: { kind: string; status: string }[]
+  ) => {
+    return !!infras.find(
+      (i) => ["docr", "gcr", "ecr"].includes(i.kind) && i.status === "created"
+    );
+  };
+
+  const hasClusterProvisioned = (
+    infras: { kind: string; status: string }[]
+  ) => {
+    return !!infras.find(
+      (i) => ["doks", "gks", "eks"].includes(i.kind) && i.status === "created"
+    );
+  };
+
+  const provisionDOCR = async (integrationId: number, tier: string) => {
+    console.log("Provisioning DOCR...");
+    try {
+      return await api
+        .createDOCR(
+          "<token>",
+          {
+            do_integration_id: integrationId,
+            docr_name: project.name,
+            docr_subscription_tier: tier,
+          },
+          {
+            project_id: project.id,
+          }
+        )
+        .then((res) => res?.data);
+    } catch (error) {
+      catchError(error);
+    }
+  };
+
+  const provisionDOKS = async (
+    integrationId: number,
+    region: string,
+    clusterName: string
+  ) => {
+    console.log("Provisioning DOKS...");
+    try {
+      return await api
+        .createDOKS(
+          "<token>",
+          {
+            do_integration_id: integrationId,
+            doks_name: clusterName,
+            do_region: region,
+            issuer_email: snap.StateHandler.user_email,
+          },
+          {
+            project_id: project.id,
+          }
+        )
+        .then((res) => res?.data);
+    } catch (error) {
+      catchError(error);
+    }
+  };
+
+  const submit = async () => {
+    const validation = validate();
+
+    if (validation.hasError) {
+      setButtonStatus(validation.error);
+      return;
+    }
+
+    let infras = [];
+    try {
+      infras = await api
+        .getInfra("<token>", {}, { project_id: project?.id })
+        .then((res) => res?.data);
+    } catch (error) {
+      setButtonStatus("Something went wrong, try again later");
+      return;
+    }
+
+    const integrationId = snap.StateHandler.provision_resources.credentials.id;
+    let registryProvisionResponse = null;
+    let clusterProvisionResponse = null;
+
+    if (snap.StateHandler.connected_registry.skip) {
+      if (!hasRegistryProvisioned(infras)) {
+        registryProvisionResponse = await provisionDOCR(integrationId, tier);
+      }
+    }
+
+    if (!hasClusterProvisioned(infras)) {
+      clusterProvisionResponse = await provisionDOKS(
+        integrationId,
+        region,
+        clusterName
+      );
+    }
+
+    nextFormStep({
+      settings: {
+        region,
+        tier,
+        cluster_name: clusterName,
+        registry_infra_id: registryProvisionResponse?.id,
+        cluster_infra_id: clusterProvisionResponse?.id,
+      },
+    });
+  };
+
+  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}
+      />
+      <Br />
+      <SaveButton
+        text="Provision resources"
+        disabled={false}
+        onClick={submit}
+        makeFlush={true}
+        clearPosition={true}
+        status={buttonStatus}
+        statusPosition={"right"}
+      />
+    </>
+  );
+};
+
+const Br = styled.div`
+  width: 100%;
+  height: 15px;
+`;
+
+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;
+  margin-top: 22px;
+  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;
+  }
+`;

Некоторые файлы не были показаны из-за большого количества измененных файлов