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

Merge branch 'beta.3.integration-frontend' of https://github.com/porter-dev/porter into beta.3.integration-frontend

mergin
Alexander Belanger 5 лет назад
Родитель
Сommit
95bccb1bbf
100 измененных файлов с 5556 добавлено и 1283 удалено
  1. 32 0
      cli/cmd/api/integration.go
  2. 87 0
      cli/cmd/api/registry.go
  3. 26 0
      cli/cmd/connect.go
  4. 114 0
      cli/cmd/connect/docr.go
  5. 43 12
      cli/cmd/docker.go
  6. 1 1
      cli/cmd/server.go
  7. 1 1
      cli/cmd/version.go
  8. 1 0
      cmd/app/main.go
  9. 69 1
      cmd/docker-credential-porter/helper/helper.go
  10. 1 0
      cmd/migrate/main.go
  11. 13 0
      dashboard/package-lock.json
  12. 1 1
      dashboard/package.json
  13. BIN
      dashboard/src/assets/My Health Connection - Appointment Details.pdf
  14. BIN
      dashboard/src/assets/loading-dots.gif
  15. 16 0
      dashboard/src/assets/settings.svg
  16. 2 1
      dashboard/src/components/ResourceTab.tsx
  17. 0 8
      dashboard/src/components/values-form/Base64InputRow.tsx
  18. 91 0
      dashboard/src/components/values-form/CheckboxList.tsx
  19. 4 4
      dashboard/src/components/values-form/Heading.tsx
  20. 16 2
      dashboard/src/components/values-form/ValuesForm.tsx
  21. 7 0
      dashboard/src/components/values-form/ValuesWrapper.tsx
  22. 10 0
      dashboard/src/index.html
  23. 16 6
      dashboard/src/main/CurrentError.tsx
  24. 3 2
      dashboard/src/main/Login.tsx
  25. 13 4
      dashboard/src/main/Main.tsx
  26. 121 63
      dashboard/src/main/home/Home.tsx
  27. 4 8
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  28. 11 4
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  29. 57 12
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  30. 38 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/GraphDisplay.tsx
  31. 95 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/ZoomPanel.tsx
  32. 9 5
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx
  33. 119 28
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  34. 1 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx
  35. 102 105
      dashboard/src/main/home/dashboard/Dashboard.tsx
  36. 42 2
      dashboard/src/main/home/integrations/IntegrationList.tsx
  37. 29 3
      dashboard/src/main/home/integrations/Integrations.tsx
  38. 0 277
      dashboard/src/main/home/modals/UpdateProjectModal.tsx
  39. 19 471
      dashboard/src/main/home/new-project/NewProject.tsx
  40. 388 0
      dashboard/src/main/home/project-settings/InviteList.tsx
  41. 177 0
      dashboard/src/main/home/project-settings/ProjectSettings.tsx
  42. 381 0
      dashboard/src/main/home/provisioner/AWSFormSection.tsx
  43. 238 0
      dashboard/src/main/home/provisioner/DOFormSection.tsx
  44. 101 0
      dashboard/src/main/home/provisioner/ExistingClusterSection.tsx
  45. 182 0
      dashboard/src/main/home/provisioner/GCPFormSection.tsx
  46. 69 0
      dashboard/src/main/home/provisioner/InfraStatuses.tsx
  47. 281 0
      dashboard/src/main/home/provisioner/ProvisionerSettings.tsx
  48. 99 67
      dashboard/src/main/home/provisioner/ProvisionerStatus.tsx
  49. 21 3
      dashboard/src/main/home/sidebar/ClusterSection.tsx
  50. 2 0
      dashboard/src/main/home/sidebar/Drawer.tsx
  51. 5 31
      dashboard/src/main/home/sidebar/ProjectSection.tsx
  52. 1 1
      dashboard/src/main/home/sidebar/ProjectSectionContainer.tsx
  53. 42 32
      dashboard/src/main/home/sidebar/Sidebar.tsx
  54. 6 1
      dashboard/src/main/home/templates/Templates.tsx
  55. 9 0
      dashboard/src/main/home/templates/expanded-template/LaunchTemplate.tsx
  56. 1 1
      dashboard/src/shared/Context.tsx
  57. 58 39
      dashboard/src/shared/api.tsx
  58. 2 0
      dashboard/src/shared/baseApi.tsx
  59. 66 0
      dashboard/src/shared/common.tsx
  60. 9 1
      dashboard/src/shared/types.tsx
  61. 38 0
      docker/nginx_remote.conf
  62. 3 0
      go.mod
  63. 4 0
      go.sum
  64. 4 2
      internal/config/config.go
  65. 68 0
      internal/forms/infra.go
  66. 28 0
      internal/forms/invite.go
  67. 2 0
      internal/forms/registry.go
  68. 5 1
      internal/forms/release.go
  69. 65 14
      internal/helm/agent.go
  70. 37 26
      internal/helm/config.go
  71. 439 0
      internal/helm/postrenderer.go
  72. 235 13
      internal/kubernetes/agent.go
  73. 27 1
      internal/kubernetes/config.go
  74. 20 0
      internal/kubernetes/provisioner/do/do.go
  75. 25 0
      internal/kubernetes/provisioner/do/docr/docr.go
  76. 25 0
      internal/kubernetes/provisioner/do/doks/doks.go
  77. 54 1
      internal/kubernetes/provisioner/global_stream.go
  78. 23 0
      internal/kubernetes/provisioner/input/docr.go
  79. 23 0
      internal/kubernetes/provisioner/input/doks.go
  80. 24 0
      internal/kubernetes/provisioner/input/ecr.go
  81. 24 0
      internal/kubernetes/provisioner/input/eks.go
  82. 23 0
      internal/kubernetes/provisioner/input/gcr.go
  83. 24 0
      internal/kubernetes/provisioner/input/gke.go
  84. 195 10
      internal/kubernetes/provisioner/provisioner.go
  85. 2 0
      internal/models/cluster.go
  86. 21 7
      internal/models/infra.go
  87. 40 1
      internal/models/integrations/gcp.go
  88. 1 0
      internal/models/integrations/integration.go
  89. 5 2
      internal/models/integrations/oauth.go
  90. 48 0
      internal/models/invite.go
  91. 3 0
      internal/models/project.go
  92. 3 0
      internal/models/registry.go
  93. 50 0
      internal/oauth/config.go
  94. 275 2
      internal/registry/registry.go
  95. 23 0
      internal/repository/gorm/auth.go
  96. 12 0
      internal/repository/gorm/cluster.go
  97. 28 0
      internal/repository/gorm/helpers_test.go
  98. 80 3
      internal/repository/gorm/infra.go
  99. 98 0
      internal/repository/gorm/invite.go
  100. 100 0
      internal/repository/gorm/invite_test.go

+ 32 - 0
cli/cmd/api/integration.go

@@ -147,3 +147,35 @@ func (c *Client) CreateBasicAuthIntegration(
 
 
 	return bodyResp, nil
 	return bodyResp, nil
 }
 }
+
+// ListOAuthIntegrationResponse is the list of oauth integrations in a project
+type ListOAuthIntegrationResponse []ints.OAuthIntegrationExternal
+
+// ListOAuthIntegrations lists the oauth integrations in a project
+func (c *Client) ListOAuthIntegrations(
+	ctx context.Context,
+	projectID uint,
+) (ListOAuthIntegrationResponse, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/projects/%d/integrations/oauth", c.BaseURL, projectID),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &ListOAuthIntegrationResponse{}
+
+	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return *bodyResp, nil
+}

+ 87 - 0
cli/cmd/api/registry.go

@@ -106,6 +106,53 @@ func (c *Client) CreateGCR(
 	return bodyResp, nil
 	return bodyResp, nil
 }
 }
 
 
+// CreateDOCRRequest represents the accepted fields for creating
+// a DOCR registry
+type CreateDOCRRequest struct {
+	Name            string `json:"name"`
+	DOIntegrationID uint   `json:"do_integration_id"`
+	URL             string `json:"url"`
+}
+
+// CreateDOCRResponse is the resulting registry after creation
+type CreateDOCRResponse models.RegistryExternal
+
+// CreateDOCR creates an Digital Ocean Container Registry integration
+func (c *Client) CreateDOCR(
+	ctx context.Context,
+	projectID uint,
+	createDOCR *CreateDOCRRequest,
+) (*CreateDOCRResponse, error) {
+	data, err := json.Marshal(createDOCR)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/projects/%d/registries", c.BaseURL, projectID),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &CreateDOCRResponse{}
+
+	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
 // ListRegistryResponse is the list of registries for a project
 // ListRegistryResponse is the list of registries for a project
 type ListRegistryResponse []models.RegistryExternal
 type ListRegistryResponse []models.RegistryExternal
 
 
@@ -243,6 +290,46 @@ func (c *Client) GetGCRAuthorizationToken(
 	return bodyResp, nil
 	return bodyResp, nil
 }
 }
 
 
+type GetDOCRTokenRequest struct {
+	ServerURL string `json:"server_url"`
+}
+
+// GetDOCRAuthorizationToken gets a DOCR authorization token
+func (c *Client) GetDOCRAuthorizationToken(
+	ctx context.Context,
+	projectID uint,
+	docrRequest *GetDOCRTokenRequest,
+) (*GetTokenResponse, error) {
+	data, err := json.Marshal(docrRequest)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/projects/%d/registries/docr/token", c.BaseURL, projectID),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	bodyResp := &GetTokenResponse{}
+	req = req.WithContext(ctx)
+
+	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
 // ListRegistryRepositoryResponse is the list of repositories in a registry
 // ListRegistryRepositoryResponse is the list of repositories in a registry
 type ListRegistryRepositoryResponse []registry.Repository
 type ListRegistryRepositoryResponse []registry.Repository
 
 

+ 26 - 0
cli/cmd/connect.go

@@ -55,6 +55,18 @@ var connectGCRCmd = &cobra.Command{
 	},
 	},
 }
 }
 
 
+var connectDOCRCmd = &cobra.Command{
+	Use:   "docr",
+	Short: "Adds a DOCR instance to a project",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, runConnectDOCR)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
 var connectHRCmd = &cobra.Command{
 var connectHRCmd = &cobra.Command{
 	Use:     "helmrepo",
 	Use:     "helmrepo",
 	Aliases: []string{"helm", "helmrepos"},
 	Aliases: []string{"helm", "helmrepos"},
@@ -103,6 +115,7 @@ func init() {
 
 
 	connectCmd.AddCommand(connectECRCmd)
 	connectCmd.AddCommand(connectECRCmd)
 	connectCmd.AddCommand(connectGCRCmd)
 	connectCmd.AddCommand(connectGCRCmd)
+	connectCmd.AddCommand(connectDOCRCmd)
 	connectCmd.AddCommand(connectHRCmd)
 	connectCmd.AddCommand(connectHRCmd)
 }
 }
 
 
@@ -154,6 +167,19 @@ func runConnectGCR(_ *api.AuthCheckResponse, client *api.Client, _ []string) err
 	return setRegistry(regID)
 	return setRegistry(regID)
 }
 }
 
 
+func runConnectDOCR(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
+	regID, err := connect.DOCR(
+		client,
+		getProjectID(),
+	)
+
+	if err != nil {
+		return err
+	}
+
+	return setRegistry(regID)
+}
+
 func runConnectHelmRepoBasic(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
 func runConnectHelmRepoBasic(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
 	hrID, err := connect.Helm(
 	hrID, err := connect.Helm(
 		client,
 		client,

+ 114 - 0
cli/cmd/connect/docr.go

@@ -0,0 +1,114 @@
+package connect
+
+import (
+	"context"
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/cli/cmd/utils"
+
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+)
+
+// DOCR creates a DOCR integration
+func DOCR(
+	client *api.Client,
+	projectID uint,
+) (uint, error) {
+	// if project ID is 0, ask the user to set the project ID or create a project
+	if projectID == 0 {
+		return 0, fmt.Errorf("no project set, please run porter project set [id]")
+	}
+
+	// list oauth integrations and make sure DO exists
+	oauthInts, err := client.ListOAuthIntegrations(context.TODO(), projectID)
+
+	if err != nil {
+		return 0, err
+	}
+
+	linkedDO := false
+	var doAuth ints.OAuthIntegrationExternal
+
+	// iterate through oauth integrations to find do
+	for _, oauthInt := range oauthInts {
+		if oauthInt.Client == ints.OAuthDigitalOcean {
+			linkedDO = true
+			doAuth = oauthInt
+			break
+		}
+	}
+
+	if !linkedDO {
+		doAuth, err = triggerDigitalOceanOAuth(client, projectID)
+
+		if err != nil {
+			return 0, err
+		}
+	}
+
+	// use the digital ocean oauth to create a registry
+	regURL, err := utils.PromptPlaintext(fmt.Sprintf(`Please provide the registry URL, in the form registry.digitalocean.com/[REGISTRY_NAME]. For example, registry.digitalocean.com/porter-test. 
+Registry URL: `))
+
+	if err != nil {
+		return 0, err
+	}
+
+	urlArr := strings.Split(regURL, "/")
+	regName := urlArr[len(urlArr)-1]
+
+	if err != nil {
+		return 0, err
+	}
+
+	reg, err := client.CreateDOCR(
+		context.Background(),
+		projectID,
+		&api.CreateDOCRRequest{
+			Name:            regName,
+			DOIntegrationID: doAuth.ID,
+			URL:             regURL,
+		},
+	)
+
+	return reg.ID, nil
+}
+
+func triggerDigitalOceanOAuth(client *api.Client, projectID uint) (ints.OAuthIntegrationExternal, error) {
+	var doAuth ints.OAuthIntegrationExternal
+
+	oauthURL := fmt.Sprintf("%s/oauth/projects/%d/digitalocean", client.BaseURL, projectID)
+
+	fmt.Printf("Please visit %s in your browser to connect to Digital Ocean (it should open automatically).", oauthURL)
+	utils.OpenBrowser(oauthURL)
+
+	for {
+		oauthInts, err := client.ListOAuthIntegrations(context.TODO(), projectID)
+
+		if err != nil {
+			return doAuth, err
+		}
+
+		linkedDO := false
+
+		// iterate through oauth integrations to find do
+		for _, oauthInt := range oauthInts {
+			if oauthInt.Client == ints.OAuthDigitalOcean {
+				linkedDO = true
+				doAuth = oauthInt
+				break
+			}
+		}
+
+		if linkedDO {
+			break
+		}
+
+		time.Sleep(2 * time.Second)
+	}
+
+	return doAuth, nil
+}

+ 43 - 12
cli/cmd/docker.go

@@ -6,9 +6,11 @@ import (
 	"io/ioutil"
 	"io/ioutil"
 	"net/url"
 	"net/url"
 	"os"
 	"os"
+	"os/exec"
 	"path/filepath"
 	"path/filepath"
 	"strings"
 	"strings"
 
 
+	"github.com/fatih/color"
 	"github.com/porter-dev/porter/cli/cmd/api"
 	"github.com/porter-dev/porter/cli/cmd/api"
 	"github.com/porter-dev/porter/cli/cmd/github"
 	"github.com/porter-dev/porter/cli/cmd/github"
 	"github.com/spf13/cobra"
 	"github.com/spf13/cobra"
@@ -93,21 +95,30 @@ func dockerConfig(user *api.AuthCheckResponse, client *api.Client, args []string
 		return err
 		return err
 	}
 	}
 
 
-	// download the porter cred helper
-	z := &github.ZIPReleaseGetter{
-		AssetName:           "docker-credential-porter",
-		AssetFolderDest:     "/usr/local/bin",
-		ZipFolderDest:       filepath.Join(home, ".porter"),
-		ZipName:             "docker-credential-porter_latest.zip",
-		EntityID:            "porter-dev",
-		RepoName:            "porter",
-		IsPlatformDependent: true,
+	// check if the docker credential helper exists
+	if !commandExists("docker-credential-porter") {
+		err := downloadCredMatchingRelease()
+
+		if err != nil {
+			color.New(color.FgRed).Println("Failed to download credential helper binary:", err.Error())
+			os.Exit(1)
+		}
 	}
 	}
 
 
-	err = z.GetLatestRelease()
+	// otherwise, check the version flag of the binary
+	cmdVersionCred := exec.Command("docker-credential-porter", "--version")
+	writer := &versionWriter{}
+	cmdVersionCred.Stdout = writer
 
 
-	if err != nil {
-		return err
+	err = cmdVersionCred.Run()
+
+	if err != nil || writer.Version != Version {
+		err := downloadCredMatchingRelease()
+
+		if err != nil {
+			color.New(color.FgRed).Println("Failed to download credential helper binary:", err.Error())
+			os.Exit(1)
+		}
 	}
 	}
 
 
 	config := &configfile.ConfigFile{
 	config := &configfile.ConfigFile{
@@ -126,3 +137,23 @@ func dockerConfig(user *api.AuthCheckResponse, client *api.Client, args []string
 
 
 	return config.Save()
 	return config.Save()
 }
 }
+
+func downloadCredMatchingRelease() error {
+	// download the porter cred helper
+	z := &github.ZIPReleaseGetter{
+		AssetName:           "docker-credential-porter",
+		AssetFolderDest:     "/usr/local/bin",
+		ZipFolderDest:       filepath.Join(home, ".porter"),
+		ZipName:             "docker-credential-porter_latest.zip",
+		EntityID:            "porter-dev",
+		RepoName:            "porter",
+		IsPlatformDependent: true,
+	}
+
+	return z.GetRelease(Version)
+}
+
+func commandExists(cmd string) bool {
+	_, err := exec.LookPath(cmd)
+	return err == nil
+}

+ 1 - 1
cli/cmd/server.go

@@ -270,7 +270,7 @@ func downloadMatchingRelease(porterDir string) error {
 		IsPlatformDependent: false,
 		IsPlatformDependent: false,
 	}
 	}
 
 
-	return zStatic.GetLatestRelease()
+	return zStatic.GetRelease(Version)
 }
 }
 
 
 type versionWriter struct {
 type versionWriter struct {

+ 1 - 1
cli/cmd/version.go

@@ -7,7 +7,7 @@ import (
 )
 )
 
 
 // Version will be linked by an ldflag during build
 // Version will be linked by an ldflag during build
-var Version string = "v0.1.0-beta.3.1"
+var Version string = "dev"
 
 
 var versionCmd = &cobra.Command{
 var versionCmd = &cobra.Command{
 	Use:     "version",
 	Use:     "version",

+ 1 - 0
cmd/app/main.go

@@ -57,6 +57,7 @@ func main() {
 		&models.ClusterCandidate{},
 		&models.ClusterCandidate{},
 		&models.ClusterResolver{},
 		&models.ClusterResolver{},
 		&models.Infra{},
 		&models.Infra{},
+		&models.Invite{},
 		&ints.KubeIntegration{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},
 		&ints.OIDCIntegration{},

+ 69 - 1
cmd/docker-credential-porter/helper/helper.go

@@ -48,13 +48,15 @@ func (p *PorterHelper) Get(serverURL string) (user string, secret string, err er
 
 
 	if strings.Contains(serverURL, "gcr.io") {
 	if strings.Contains(serverURL, "gcr.io") {
 		return p.getGCR(serverURL)
 		return p.getGCR(serverURL)
+	} else if strings.Contains(serverURL, "registry.digitalocean.com") {
+		return p.getDOCR(serverURL)
 	}
 	}
 
 
 	return p.getECR(serverURL)
 	return p.getECR(serverURL)
 }
 }
 
 
 func (p *PorterHelper) getGCR(serverURL string) (user string, secret string, err error) {
 func (p *PorterHelper) getGCR(serverURL string) (user string, secret string, err error) {
-	urlP, err := url.Parse(serverURL)
+	urlP, err := url.Parse("https://" + serverURL)
 
 
 	if err != nil {
 	if err != nil {
 		return "", "", err
 		return "", "", err
@@ -96,6 +98,72 @@ func (p *PorterHelper) getGCR(serverURL string) (user string, secret string, err
 	return "oauth2accesstoken", token, nil
 	return "oauth2accesstoken", token, nil
 }
 }
 
 
+func (p *PorterHelper) getDOCR(serverURL string) (user string, secret string, err error) {
+	urlP, err := url.Parse("https://" + serverURL)
+
+	if err != nil {
+		if p.Debug {
+			log.Printf("Error: %s\n", err.Error())
+		}
+
+		return "", "", err
+	}
+
+	credCache := BuildCredentialsCache(urlP.Host)
+	cachedEntry := credCache.Get(serverURL)
+
+	var token string
+
+	if p.Debug {
+		log.Printf("GETTING FROM DOCR", urlP)
+	}
+
+	if cachedEntry != nil && cachedEntry.IsValid(time.Now()) {
+		token = cachedEntry.AuthorizationToken
+
+		if p.Debug {
+			log.Printf("USING CACHED TOKEN", token)
+		}
+	} else {
+		host := viper.GetString("host")
+		projID := viper.GetUint("project")
+
+		client := api.NewClient(host+"/api", "cookie.json")
+
+		if p.Debug {
+			log.Printf("MAKING REQUEST", host, projID)
+		}
+
+		// get a token from the server
+		tokenResp, err := client.GetDOCRAuthorizationToken(context.Background(), projID, &api.GetDOCRTokenRequest{
+			ServerURL: serverURL,
+		})
+
+		if err != nil {
+			if p.Debug {
+				log.Printf("Error: %s\n", err.Error())
+			}
+
+			return "", "", err
+		}
+
+		token = tokenResp.Token
+
+		if t := *tokenResp.ExpiresAt; len(token) > 0 && !t.IsZero() {
+			// set the token in cache
+			credCache.Set(serverURL, &AuthEntry{
+				AuthorizationToken: token,
+				RequestedAt:        time.Now(),
+				ExpiresAt:          t,
+				ProxyEndpoint:      serverURL,
+			})
+		}
+
+	}
+
+	return token, token, nil
+}
+
 func (p *PorterHelper) getECR(serverURL string) (user string, secret string, err error) {
 func (p *PorterHelper) getECR(serverURL string) (user string, secret string, err error) {
 	// parse the server url for region
 	// parse the server url for region
 	matches := ecrPattern.FindStringSubmatch(serverURL)
 	matches := ecrPattern.FindStringSubmatch(serverURL)

+ 1 - 0
cmd/migrate/main.go

@@ -37,6 +37,7 @@ func main() {
 		&models.ClusterCandidate{},
 		&models.ClusterCandidate{},
 		&models.ClusterResolver{},
 		&models.ClusterResolver{},
 		&models.Infra{},
 		&models.Infra{},
+		&models.Invite{},
 		&ints.KubeIntegration{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},
 		&ints.OIDCIntegration{},

+ 13 - 0
dashboard/package-lock.json

@@ -2944,6 +2944,11 @@
         "websocket-driver": ">=0.5.1"
         "websocket-driver": ">=0.5.1"
       }
       }
     },
     },
+    "fflate": {
+      "version": "0.4.8",
+      "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz",
+      "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA=="
+    },
     "figgy-pudding": {
     "figgy-pudding": {
       "version": "3.5.2",
       "version": "3.5.2",
       "resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.2.tgz",
       "resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.2.tgz",
@@ -5356,6 +5361,14 @@
       "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz",
       "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz",
       "integrity": "sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ=="
       "integrity": "sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ=="
     },
     },
+    "posthog-js": {
+      "version": "1.8.5",
+      "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.8.5.tgz",
+      "integrity": "sha512-YwKejadn4GAjqbA3cvpmou2As3P0nMSt76kySRmfH/XmM00YDs8o4WFrorWHJvDAbln46CXIzAzfgS71sZ+gXw==",
+      "requires": {
+        "fflate": "^0.4.1"
+      }
+    },
     "posthog-node": {
     "posthog-node": {
       "version": "1.0.6",
       "version": "1.0.6",
       "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-1.0.6.tgz",
       "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-1.0.6.tgz",

+ 1 - 1
dashboard/package.json

@@ -17,7 +17,7 @@
     "js-yaml": "^3.14.0",
     "js-yaml": "^3.14.0",
     "lodash": "^4.17.20",
     "lodash": "^4.17.20",
     "markdown-to-jsx": "^7.0.1",
     "markdown-to-jsx": "^7.0.1",
-    "posthog-node": "^1.0.6",
+    "posthog-js": "^1.8.5",
     "qs": "^6.9.4",
     "qs": "^6.9.4",
     "random-words": "^1.1.1",
     "random-words": "^1.1.1",
     "react": "^16.13.1",
     "react": "^16.13.1",

BIN
dashboard/src/assets/My Health Connection - Appointment Details.pdf


BIN
dashboard/src/assets/loading-dots.gif


+ 16 - 0
dashboard/src/assets/settings.svg

@@ -0,0 +1,16 @@
+<svg width="28" height="30" viewBox="0 0 28 30" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g filter="url(#filter0_d)">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M22.4023 13.5801C22.7599 13.7701 23.0359 14.0701 23.23 14.3701C23.6082 14.9901 23.5775 15.7501 23.2096 16.4201L22.4942 17.6201C22.1161 18.2601 21.411 18.6601 20.6854 18.6601C20.3277 18.6601 19.9291 18.5601 19.6021 18.3601C19.3364 18.1901 19.0298 18.1301 18.7028 18.1301C17.691 18.1301 16.8428 18.9601 16.8121 19.9501C16.8121 21.1001 15.8719 22.0001 14.6967 22.0001H13.3068C12.1214 22.0001 11.1812 21.1001 11.1812 19.9501C11.1607 18.9601 10.3125 18.1301 9.30076 18.1301C8.96351 18.1301 8.65693 18.1901 8.40144 18.3601C8.07441 18.5601 7.66563 18.6601 7.31816 18.6601C6.58235 18.6601 5.8772 18.2601 5.49908 17.6201L4.79393 16.4201C4.4158 15.7701 4.39536 14.9901 4.77349 14.3701C4.937 14.0701 5.24359 13.7701 5.59106 13.5801C5.8772 13.4401 6.06116 13.2101 6.23489 12.9401C6.74587 12.0801 6.43928 10.9501 5.57062 10.4401C4.55888 9.87012 4.23185 8.60012 4.81437 7.61012L5.49908 6.43012C6.09181 5.44012 7.35904 5.09012 8.381 5.67012C9.2701 6.15012 10.4249 5.83012 10.9461 4.98012C11.1096 4.70012 11.2016 4.40012 11.1812 4.10012C11.1607 3.71012 11.2731 3.34012 11.4673 3.04012C11.8454 2.42012 12.5301 2.02012 13.2762 2.00012H14.7171C15.4734 2.00012 16.1581 2.42012 16.5362 3.04012C16.7202 3.34012 16.8428 3.71012 16.8121 4.10012C16.7917 4.40012 16.8837 4.70012 17.0472 4.98012C17.5684 5.83012 18.7232 6.15012 19.6225 5.67012C20.6343 5.09012 21.9117 5.44012 22.4942 6.43012L23.1789 7.61012C23.7717 8.60012 23.4447 9.87012 22.4227 10.4401C21.554 10.9501 21.2474 12.0801 21.7686 12.9401C21.9322 13.2101 22.1161 13.4401 22.4023 13.5801ZM11.1096 12.0101C11.1096 13.5801 12.4075 14.8301 14.012 14.8301C15.6165 14.8301 16.8837 13.5801 16.8837 12.0101C16.8837 10.4401 15.6165 9.18012 14.012 9.18012C12.4075 9.18012 11.1096 10.4401 11.1096 12.0101Z" fill="white"/>
+</g>
+<defs>
+<filter id="filter0_d" x="-2" y="0" width="32" height="32" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
+<feFlood flood-opacity="0" result="BackgroundImageFix"/>
+<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
+<feOffset dy="4"/>
+<feGaussianBlur stdDeviation="2"/>
+<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
+<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
+<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
+</filter>
+</defs>
+</svg>

+ 2 - 1
dashboard/src/components/ResourceTab.tsx

@@ -15,6 +15,7 @@ type PropsType = {
     available?: number,
     available?: number,
     total?: number,
     total?: number,
   } | null
   } | null
+  expanded?: boolean,
 };
 };
 
 
 type StateType = {
 type StateType = {
@@ -24,7 +25,7 @@ type StateType = {
 
 
 export default class ResourceTab extends Component<PropsType, StateType> {
 export default class ResourceTab extends Component<PropsType, StateType> {
   state = {
   state = {
-    expanded: false,
+    expanded: this.props.expanded || false,
     showTooltip: false,
     showTooltip: false,
   }
   }
 
 

+ 0 - 8
dashboard/src/components/values-form/Base64InputRow.tsx

@@ -28,14 +28,6 @@ export default class InputRow extends Component<PropsType, StateType> {
   
   
   render() {
   render() {
     let { label, value, type, unit, placeholder, width } = this.props;
     let { label, value, type, unit, placeholder, width } = this.props;
-    if (type === 'b64') {
-      type = 'string-input';
-    } else if (type === 'b64-pass') {
-      type = 'password';
-    }
-    if (value === undefined) {
-        value = '';
-    }
     value = value.toString();
     value = value.toString();
     value = atob(value);
     value = atob(value);
     return (
     return (

+ 91 - 0
dashboard/src/components/values-form/CheckboxList.tsx

@@ -0,0 +1,91 @@
+import React from 'react';
+import styled from 'styled-components';
+
+type PropsType = {
+  label?: string,
+  options: { disabled?: boolean, value: string, label: string }[],
+  selected: { value: string, label: string }[],
+  setSelected: (x: { value: string, label: string }[]) => void,
+};
+
+const CheckboxList = ({ 
+  label, options, selected, setSelected,
+}: PropsType) => {
+  let onSelectOption = (option: { value: string, label: string }) => {
+    if (!selected.includes(option)) {
+      selected.push(option);
+      setSelected(selected);
+    } else {
+      selected.splice(selected.indexOf(option), 1);
+      setSelected(selected);
+    }
+  }
+  
+  return (
+    <StyledCheckboxList>
+      {label && <Label>{label}</Label>}
+      {options.map((option: { value: string, label: string }, i: number) => {
+        return (
+          <CheckboxOption 
+            isLast={i === options.length - 1}
+            onClick={() => onSelectOption(option)}
+            key={i}
+          >
+            <Checkbox checked={selected.includes(option)}>
+              <i className="material-icons">done</i>
+            </Checkbox>
+            {option.label}
+          </CheckboxOption>
+        );
+      })}
+    </StyledCheckboxList>
+  );
+}
+export default CheckboxList;
+
+const Checkbox = styled.div`
+  width: 16px;
+  height: 16px;
+  border: 1px solid #ffffff55;
+  margin: 1px 15px 0px 1px;
+  border-radius: 3px;
+  background: ${(props: { checked: boolean }) => props.checked ? '#ffffff22' : '#ffffff11'};
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  > i {
+    font-size: 12px;
+    padding-left: 0px;
+    display: ${(props: { checked: boolean }) => props.checked ? '' : 'none'};
+  }
+`;
+
+const CheckboxOption = styled.div<{ isLast: boolean }>`
+  width: 100%;
+  height: 35px;
+  padding-left: 10px;
+  display: flex;
+  cursor: pointer;
+  align-items: center;
+  border-bottom: ${props => props.isLast ? '' : '1px solid #ffffff22'};
+  font-size: 13px;
+
+  :hover {
+    background: #ffffff18;
+  }
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+`;
+
+const StyledCheckboxList = styled.div`
+  border-radius: 3px;
+  border: 1px solid #ffffff55;
+  padding: 0;
+  background: #ffffff11;
+  margin-bottom: 15px;
+  margin-top: 20px;
+`;

+ 4 - 4
dashboard/src/components/values-form/Heading.tsx

@@ -1,15 +1,15 @@
 import React from 'react';  
 import React from 'react';  
 import styled from 'styled-components';
 import styled from 'styled-components';
 
 
-export default function Heading(props: { children: any }) {
-  return <StyledHeading>{props.children}</StyledHeading>;
+export default function Heading(props: { isAtTop?: boolean, children: any }) {
+  return <StyledHeading isAtTop={props.isAtTop}>{props.children}</StyledHeading>;
 }
 }
 
 
-const StyledHeading = styled.div`
+const StyledHeading = styled.div<{ isAtTop: boolean }>`
   color: white;
   color: white;
   font-weight: 500;
   font-weight: 500;
   font-size: 16px;
   font-size: 16px;
-  margin-top: 30px;
+  margin-top: ${props => props.isAtTop ? '0': '30px'};
   margin-bottom: 5px;
   margin-bottom: 5px;
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;

+ 16 - 2
dashboard/src/components/values-form/ValuesForm.tsx

@@ -92,6 +92,20 @@ export default class ValuesForm extends Component<PropsType, StateType> {
               label={item.label}
               label={item.label}
             />
             />
           );
           );
+        case 'array-input':
+          return (
+            <InputRow
+              key={i}
+              isRequired={item.required}
+              type='text'
+              value={this.getInputValue(item)}
+              setValue={(x: string) => {
+                this.props.setMetaState({ [key]: [x] });
+              }}
+              label={item.label}
+              unit={item.settings ? item.settings.unit : null}
+            />
+          );
         case 'string-input':
         case 'string-input':
           return (
           return (
             <InputRow
             <InputRow
@@ -155,7 +169,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
             <Base64InputRow
             <Base64InputRow
               key={i}
               key={i}
               isRequired={item.required}
               isRequired={item.required}
-              type='b64'
+              type='text'
               value={this.getInputValue(item)}
               value={this.getInputValue(item)}
               setValue={(x: string) => {
               setValue={(x: string) => {
                 if (item.settings && item.settings.unit && x !== '') {
                 if (item.settings && item.settings.unit && x !== '') {
@@ -172,7 +186,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
             <Base64InputRow
             <Base64InputRow
               key={i}
               key={i}
               isRequired={item.required}
               isRequired={item.required}
-              type='b64-pass'
+              type='password'
               value={this.getInputValue(item)}
               value={this.getInputValue(item)}
               setValue={(x: string) => {
               setValue={(x: string) => {
                 if (item.settings && item.settings.unit && x !== '') {
                 if (item.settings && item.settings.unit && x !== '') {

+ 7 - 0
dashboard/src/components/values-form/ValuesWrapper.tsx

@@ -47,12 +47,19 @@ export default class ValuesWrapper extends Component<PropsType, StateType> {
               case 'string-input':
               case 'string-input':
                 metaState[key] = def ? def : '';
                 metaState[key] = def ? def : '';
                 break;
                 break;
+              case 'array-input':
+                metaState[key] = def ? def : [];
+                break;
               case 'number-input':
               case 'number-input':
                 metaState[key] = def.toString() ? def : '';
                 metaState[key] = def.toString() ? def : '';
                 break;
                 break;
               case 'select':
               case 'select':
                 metaState[key] = def ? def : item.settings.options[0].value;
                 metaState[key] = def ? def : item.settings.options[0].value;
                 break;
                 break;
+              case 'base-64':
+                metaState[key] = def ? def : '';
+              case 'base-64-password':
+                metaState[key] = def ? def : '';
               default:
               default:
             }
             }
           });
           });

+ 10 - 0
dashboard/src/index.html

@@ -19,5 +19,15 @@
   </head>
   </head>
 <body>
 <body>
   <div id="output"></div>
   <div id="output"></div>
+  <script>
+    window.intercomSettings = {
+      app_id: "gq56g49i"
+    };
+  </script>
+
+  <script>
+  // We pre-filled your app ID in the widget URL: 'https://widget.intercom.io/widget/gq56g49i'
+  (function(){var w=window;var ic=w.Intercom;if(typeof ic==="function"){ic('reattach_activator');ic('update',w.intercomSettings);}else{var d=document;var i=function(){i.c(arguments);};i.q=[];i.c=function(args){i.q.push(args);};w.Intercom=i;var l=function(){var s=d.createElement('script');s.type='text/javascript';s.async=true;s.src='https://widget.intercom.io/widget/gq56g49i';var x=d.getElementsByTagName('script')[0];x.parentNode.insertBefore(s,x);};if(w.attachEvent){w.attachEvent('onload',l);}else{w.addEventListener('load',l,false);}}})();
+  </script>
 </body>
 </body>
 </html>
 </html>

+ 16 - 6
dashboard/src/main/CurrentError.tsx

@@ -5,6 +5,7 @@ import close from '../assets/close.png';
 import { Context } from '../shared/Context';
 import { Context } from '../shared/Context';
 
 
 type PropsType = {
 type PropsType = {
+  currentError: string,
 };
 };
 
 
 type StateType = {
 type StateType = {
@@ -12,15 +13,24 @@ type StateType = {
 
 
 export default class CurrentError extends Component<PropsType, StateType> {
 export default class CurrentError extends Component<PropsType, StateType> {
   state = {
   state = {
-    expanded: false
+    expanded: false,
+  }
+
+  componentDidUpdate(prevProps: PropsType) {
+    if (
+      prevProps.currentError !== this.props.currentError
+      && this.props.currentError === 'Provisioning failed. Check your credentials and try again.'
+    ) {
+      this.setState({ expanded: true });
+    }
   }
   }
   
   
   render() {
   render() {
-    if (this.context.currentError) {
+    if (this.props.currentError) {
       if (!this.state.expanded) {
       if (!this.state.expanded) {
         return (
         return (
           <StyledCurrentError onClick={() => this.setState({ expanded: true })}>
           <StyledCurrentError onClick={() => this.setState({ expanded: true })}>
-            <ErrorText>Error: {this.context.currentError}</ErrorText>
+            <ErrorText>Error: {this.props.currentError}</ErrorText>
             <CloseButton onClick={(e) => {
             <CloseButton onClick={(e) => {
               this.context.setCurrentError(null);
               this.context.setCurrentError(null);
               e.stopPropagation();
               e.stopPropagation();
@@ -33,7 +43,7 @@ export default class CurrentError extends Component<PropsType, StateType> {
 
 
       return (
       return (
         <ExpandedError onClick={() => this.setState({ expanded: false })}>
         <ExpandedError onClick={() => this.setState({ expanded: false })}>
-          Error: {this.context.currentError}
+          Error: {this.props.currentError}
           <CloseButtonAlt onClick={() => this.context.setCurrentError(null)}>
           <CloseButtonAlt onClick={() => this.context.setCurrentError(null)}>
             <CloseButtonImg src={close} />
             <CloseButtonImg src={close} />
           </CloseButtonAlt>
           </CloseButtonAlt>
@@ -80,9 +90,9 @@ const ErrorText = styled.div`
 
 
 const StyledCurrentError = styled.div`
 const StyledCurrentError = styled.div`
   position: fixed;
   position: fixed;
-  bottom: 20px;
+  bottom: 22px;
   width: 300px;
   width: 300px;
-  left: 17px;
+  left: 100px;
   padding: 15px;
   padding: 15px;
   padding-right: 0px;
   padding-right: 0px;
   font-family: 'Work Sans', sans-serif;
   font-family: 'Work Sans', sans-serif;

+ 3 - 2
dashboard/src/main/Login.tsx

@@ -43,7 +43,7 @@ export default class Login extends Component<PropsType, StateType> {
   handleLogin = (): void => {
   handleLogin = (): void => {
     let { email, password } = this.state;
     let { email, password } = this.state;
     let { authenticate } = this.props;
     let { authenticate } = this.props;
-    let { setCurrentError, setUser } = this.context;
+    let { setUser } = this.context;
 
 
     // Check for valid input
     // Check for valid input
     if (!emailRegex.test(email)) {
     if (!emailRegex.test(email)) {
@@ -55,8 +55,9 @@ export default class Login extends Component<PropsType, StateType> {
         password: password
         password: password
       }, {}, (err: any, res: any) => {
       }, {}, (err: any, res: any) => {
         // TODO: case and set credential error
         // TODO: case and set credential error
+        console.log(res.data);
         setUser(res?.data?.id, res?.data?.email)
         setUser(res?.data?.id, res?.data?.email)
-        err ? setCurrentError(err.response.data.errors[0]) : authenticate();
+        err ? console.log(err) : authenticate();
       });
       });
     }
     }
   }
   }

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

@@ -31,8 +31,8 @@ export default class Main extends Component<PropsType, StateType> {
 
 
   componentDidMount() {
   componentDidMount() {
     let { setUser } = this.context;
     let { setUser } = this.context;
-    api.checkAuth('', {}, {}, (err: any, res: any) => {      
-      if (err && err.response.status == 403) {
+    api.checkAuth('', {}, {}, (err: any, res: any) => {    
+      if (err && err.response?.status == 403) {
         this.setState({ isLoggedIn: false, loading: false })
         this.setState({ isLoggedIn: false, loading: false })
       }
       }
 
 
@@ -55,6 +55,9 @@ export default class Main extends Component<PropsType, StateType> {
   }
   }
 
 
   handleLogOut = () => {
   handleLogOut = () => {
+    // Clears local storage for proper rendering of clusters
+    localStorage.clear();
+
     this.context.clearContext();
     this.context.clearContext();
     this.setState({ isLoggedIn: false, initialized: true });
     this.setState({ isLoggedIn: false, initialized: true });
   }
   }
@@ -84,7 +87,13 @@ export default class Main extends Component<PropsType, StateType> {
 
 
         <Route path='/dashboard' render={() => {
         <Route path='/dashboard' render={() => {
           if (this.state.isLoggedIn && this.state.initialized) {
           if (this.state.isLoggedIn && this.state.initialized) {
-            return <Home logOut={this.handleLogOut} />
+            return (
+              <Home 
+                currentProject={this.context.currentProject}
+                currentCluster={this.context.currentCluster} 
+                logOut={this.handleLogOut} 
+              />
+            );
           } else {
           } else {
             return <Redirect to='/' />
             return <Redirect to='/' />
           }
           }
@@ -110,7 +119,7 @@ export default class Main extends Component<PropsType, StateType> {
         <BrowserRouter>
         <BrowserRouter>
           {this.renderMain()}
           {this.renderMain()}
         </BrowserRouter>
         </BrowserRouter>
-        <CurrentError />
+        <CurrentError currentError={this.context.currentError} />
       </StyledMain>
       </StyledMain>
     );
     );
   }
   }

+ 121 - 63
dashboard/src/main/home/Home.tsx

@@ -1,10 +1,12 @@
 import React, { Component } from 'react';
 import React, { Component } from 'react';
+import posthog from 'posthog-js';
 import styled from 'styled-components';
 import styled from 'styled-components';
 import ReactModal from 'react-modal';
 import ReactModal from 'react-modal';
 
 
 import { Context } from '../../shared/Context';
 import { Context } from '../../shared/Context';
 import api from '../../shared/api';
 import api from '../../shared/api';
-import { InfraType } from '../../shared/types';
+import { ClusterType, ProjectType } from '../../shared/types';
+import { includesCompletedInfraSet } from '../../shared/common';
 
 
 import Sidebar from './sidebar/Sidebar';
 import Sidebar from './sidebar/Sidebar';
 import Dashboard from './dashboard/Dashboard';
 import Dashboard from './dashboard/Dashboard';
@@ -12,24 +14,26 @@ import ClusterDashboard from './cluster-dashboard/ClusterDashboard';
 import Loading from '../../components/Loading';
 import Loading from '../../components/Loading';
 import Templates from './templates/Templates';
 import Templates from './templates/Templates';
 import Integrations from "./integrations/Integrations";
 import Integrations from "./integrations/Integrations";
-import UpdateProjectModal from './modals/UpdateProjectModal';
 import UpdateClusterModal from './modals/UpdateClusterModal';
 import UpdateClusterModal from './modals/UpdateClusterModal';
 import ClusterInstructionsModal from './modals/ClusterInstructionsModal';
 import ClusterInstructionsModal from './modals/ClusterInstructionsModal';
 import IntegrationsModal from './modals/IntegrationsModal';
 import IntegrationsModal from './modals/IntegrationsModal';
 import IntegrationsInstructionsModal from './modals/IntegrationsInstructionsModal';
 import IntegrationsInstructionsModal from './modals/IntegrationsInstructionsModal';
 import NewProject from './new-project/NewProject';
 import NewProject from './new-project/NewProject';
 import Navbar from './navbar/Navbar';
 import Navbar from './navbar/Navbar';
-import Provisioner from './new-project/Provisioner';
+import ProvisionerStatus from './provisioner/ProvisionerStatus';
+import ProjectSettings from './project-settings/ProjectSettings';
+import ConfirmOverlay from '../../components/ConfirmOverlay';
 
 
 type PropsType = {
 type PropsType = {
-  logOut: () => void
+  logOut: () => void,
+  currentProject: ProjectType,
+  currentCluster: ClusterType,
 };
 };
 
 
 type StateType = {
 type StateType = {
   forceSidebar: boolean,
   forceSidebar: boolean,
   showWelcome: boolean,
   showWelcome: boolean,
   currentView: string,
   currentView: string,
-  viewData: any[],
   forceRefreshClusters: boolean, // For updating ClusterSection from modal on deletion
   forceRefreshClusters: boolean, // For updating ClusterSection from modal on deletion
 
 
   // Track last project id for refreshing clusters on project change
   // Track last project id for refreshing clusters on project change
@@ -37,80 +41,74 @@ type StateType = {
   sidebarReady: boolean, // Fixes error where ~1/3 times reloading to provisioner fails
   sidebarReady: boolean, // Fixes error where ~1/3 times reloading to provisioner fails
 };
 };
 
 
+// TODO: Handle cluster connected but with some failed infras (no successful set)
 export default class Home extends Component<PropsType, StateType> {
 export default class Home extends Component<PropsType, StateType> {
   state = {
   state = {
     forceSidebar: true,
     forceSidebar: true,
     showWelcome: false,
     showWelcome: false,
     currentView: 'dashboard',
     currentView: 'dashboard',
     prevProjectId: null as number | null,
     prevProjectId: null as number | null,
-    viewData: null as any,
     forceRefreshClusters: false,
     forceRefreshClusters: false,
     sidebarReady: false,
     sidebarReady: false,
   }
   }
 
 
-  // Possibly consolidate into context (w/ ProjectSection + NewProject)
+  initializeView = () => {
+    let { currentCluster } = this.context;
+    let { currentProject } = this.props;
+    // Check if current project is provisioning
+    api.getInfra('<token>', {}, { project_id: currentProject.id }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+        return;
+      }
+      console.log(currentCluster);
+      if (!currentCluster && !includesCompletedInfraSet(res.data)) {
+        this.setState({ currentView: 'provisioner', sidebarReady: true, });
+      } else {
+        this.setState({ currentView: 'dashboard', sidebarReady: true });
+      }
+    });
+  }
+
   getProjects = () => {
   getProjects = () => {
-    let { user, currentProject, projects, setProjects } = this.context;
+    let { user, setProjects } = this.context;
+    let { currentProject } = this.props;
     api.getProjects('<token>', {}, { id: user.userId }, (err: any, res: any) => {
     api.getProjects('<token>', {}, { id: user.userId }, (err: any, res: any) => {
       if (err) {
       if (err) {
         console.log(err);
         console.log(err);
       } else if (res.data) {
       } else if (res.data) {
-        setProjects(res.data);
-        if (res.data.length > 0 && !currentProject) {
+        if (res.data.length === 0) {
+          this.setState({ currentView: 'new-project', sidebarReady: true, });
+        } else if (res.data.length > 0 && !currentProject) {
+          setProjects(res.data);
           this.context.setCurrentProject(res.data[0]);
           this.context.setCurrentProject(res.data[0]);
 
 
-          // Check if current project is provisioning
-          api.getInfra('<token>', {}, { project_id: res.data[0].id }, (err: any, res: any) => {
-            if (err) {
-              console.log(err);
-            } else if (res.data) {
-
-              let viewData = [] as any[]
-              // TODO: separately handle non meta-provisioning case
-              res.data.forEach((el: InfraType) => {
-                if (el.status === 'creating') {
-                  viewData.push({
-                    infra_id: el.id,
-                    kind: el.kind,
-                  });
-                }
-              });
-              
-              if (viewData.length > 0) {
-                this.setState({ currentView: 'provisioner', viewData, sidebarReady: true, });
-              } else {
-                this.setState({ sidebarReady: true });
-              }
-            }
-          });
-        } else if (res.data.length === 0) {
-          this.setState({ currentView: 'new-project', sidebarReady: true, });
+          this.initializeView();
         }
         }
       }
       }
     });
     });
   }
   }
 
 
   componentDidMount() {
   componentDidMount() {
+    let { user } = this.context;
+    window.location.href.indexOf('127.0.0.1') === -1 && posthog.init(process.env.POSTHOG_API_KEY, {
+      api_host: process.env.POSTHOG_HOST,
+      loaded: function(posthog: any) { posthog.identify(user.email) }
+    })
+
     this.getProjects();
     this.getProjects();
   }
   }
 
 
   componentDidUpdate(prevProps: PropsType) {
   componentDidUpdate(prevProps: PropsType) {
-    if (prevProps !== this.props && this.context.currentProject) {
-
-      // Set view to dashboard on project change
-      if (this.state.prevProjectId && this.state.prevProjectId !== this.context.currentProject.id) {
-        this.setState({
-          prevProjectId: this.context.currentProject.id,
-          currentView: 'dashboard'
-        });
-      }
+    if (prevProps.currentProject !== this.props.currentProject) {
+      this.initializeView();
     }
     }
   }
   }
 
 
   // TODO: move into ClusterDashboard
   // TODO: move into ClusterDashboard
   renderDashboard = () => {
   renderDashboard = () => {
     let { currentCluster, setCurrentModal } = this.context;
     let { currentCluster, setCurrentModal } = this.context;
-    if (this.state.showWelcome || currentCluster && !currentCluster.name) {
+    if (currentCluster && !currentCluster.name) {
       return (
       return (
         <DashboardWrapper>
         <DashboardWrapper>
           <Placeholder>
           <Placeholder>
@@ -147,22 +145,28 @@ export default class Home extends Component<PropsType, StateType> {
     } else if (currentView === 'dashboard') {
     } else if (currentView === 'dashboard') {
       return (
       return (
         <DashboardWrapper>
         <DashboardWrapper>
-          <Dashboard setCurrentView={(x: string) => this.setState({ currentView: x })} />
+          <Dashboard 
+            setCurrentView={(x: string) => this.setState({ currentView: x })}
+            projectId={this.context.currentProject?.id}
+          />
         </DashboardWrapper>
         </DashboardWrapper>
       );
       );
     } else if (currentView === 'integrations') {
     } else if (currentView === 'integrations') {
       return <Integrations />;
       return <Integrations />;
     } else if (currentView === 'new-project') {
     } else if (currentView === 'new-project') {
       return (
       return (
-        <NewProject setCurrentView={(x: string, data: any ) => this.setState({ currentView: x, viewData: data })} />
+        <NewProject setCurrentView={(x: string, data: any ) => this.setState({ currentView: x })} />
       );
       );
     } else if (currentView === 'provisioner') {
     } else if (currentView === 'provisioner') {
       return (
       return (
-        <Provisioner 
+        <ProvisionerStatus
           setCurrentView={(x: string) => this.setState({ currentView: x })}
           setCurrentView={(x: string) => this.setState({ currentView: x })}
-          viewData={this.state.viewData}
         />
         />
       );
       );
+    } else if (currentView === 'project-settings') {
+      return (
+        <ProjectSettings  setCurrentView={(x: string) => this.setState({ currentView: x })} />
+      )
     }
     }
 
 
     return (
     return (
@@ -172,11 +176,11 @@ export default class Home extends Component<PropsType, StateType> {
     );
     );
   }
   }
 
 
-  setCurrentView = (x: string, viewData?: any) => {
-    if (!viewData) {
-      this.setState({ currentView: x });
+  setCurrentView = (x: string) => {
+    if (x === 'dashboard') {
+      this.initializeView();
     } else {
     } else {
-      this.setState({ currentView: x, viewData });
+      this.setState({ currentView: x });
     }
     }
   }
   }
 
 
@@ -186,7 +190,7 @@ export default class Home extends Component<PropsType, StateType> {
       // Force sidebar closed on first provision
       // Force sidebar closed on first provision
       if (this.state.currentView === 'provisioner' && this.state.forceSidebar) {
       if (this.state.currentView === 'provisioner' && this.state.forceSidebar) {
         this.setState({ forceSidebar: false });
         this.setState({ forceSidebar: false });
-      } else if (this.state.sidebarReady) {
+      } else {
         return (
         return (
           <Sidebar
           <Sidebar
             forceSidebar={this.state.forceSidebar}
             forceSidebar={this.state.forceSidebar}
@@ -201,6 +205,61 @@ export default class Home extends Component<PropsType, StateType> {
     }
     }
   }
   }
 
 
+  projectOverlayCall = () => {
+    let { user, setProjects } = this.context;
+    api.getProjects('<token>', {}, { id: user.userId }, (err: any, res: any) => {
+      if (err) {
+        console.log(err)
+      } else if (res.data) {
+        setProjects(res.data);
+        if (res.data.length > 0) {
+          this.context.setCurrentProject(res.data[0]);
+        } else {
+          this.context.currentModalData.setCurrentView('new-project');
+        }
+        this.context.setCurrentModal(null, null);
+      }
+    });
+  }
+
+  handleDelete = () => {
+    let { setCurrentModal, currentProject } = this.context;
+    api.deleteProject('<token>', {}, { id: currentProject.id }, (err: any, res: any) => {
+      if (err) {
+        // console.log(err)
+      } else {
+        this.projectOverlayCall();
+      }
+    });
+
+    // Loop through and delete infra of all clusters we've provisioned
+    api.getClusters('<token>', {}, { id: currentProject.id }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else {
+        res.data.forEach((cluster: ClusterType) => {
+
+          // Handle destroying infra we've provisioned
+          if (cluster.infra_id) {
+            console.log('destroying provisioned infra...', cluster.infra_id);
+            api.destroyCluster('<token>', { eks_name: cluster.name }, { 
+              project_id: currentProject.id,
+              infra_id: cluster.infra_id,
+            }, (err: any, res: any) => {
+              if (err) {
+                console.log(err)
+              } else {
+                console.log('destroyed provisioned infra:', cluster.infra_id);
+              }
+            });
+          }
+        });
+      }
+    });
+    setCurrentModal(null, null)
+    this.setState({ currentView: 'dashboard' });
+  }
+
   render() {
   render() {
     let { currentModal, setCurrentModal, currentProject } = this.context;
     let { currentModal, setCurrentModal, currentProject } = this.context;
     return (
     return (
@@ -213,14 +272,6 @@ export default class Home extends Component<PropsType, StateType> {
         >
         >
           <ClusterInstructionsModal />
           <ClusterInstructionsModal />
         </ReactModal>
         </ReactModal>
-        <ReactModal
-          isOpen={currentModal === 'UpdateProjectModal'}
-          onRequestClose={() => setCurrentModal(null, null)}
-          style={ProjectModalStyles}
-          ariaHideApp={false}
-        >
-          <UpdateProjectModal />
-        </ReactModal>
         <ReactModal
         <ReactModal
           isOpen={currentModal === 'UpdateClusterModal'}
           isOpen={currentModal === 'UpdateClusterModal'}
           onRequestClose={() => setCurrentModal(null, null)}
           onRequestClose={() => setCurrentModal(null, null)}
@@ -257,6 +308,13 @@ export default class Home extends Component<PropsType, StateType> {
           />
           />
           {this.renderContents()}
           {this.renderContents()}
         </ViewWrapper>
         </ViewWrapper>
+
+        <ConfirmOverlay
+          show={currentModal === 'UpdateProjectModal'}
+          message={(currentProject) ? `Are you sure you want to delete ${currentProject.name}?` : ''}
+          onYes={this.handleDelete}
+          onNo={() => setCurrentModal(null, null)}
+        />
       </StyledHome>
       </StyledHome>
     );
     );
   }
   }

+ 4 - 8
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -26,21 +26,17 @@ type StateType = {
 export default class ClusterDashboard extends Component<PropsType, StateType> {
 export default class ClusterDashboard extends Component<PropsType, StateType> {
   state = {
   state = {
     namespace: 'default',
     namespace: 'default',
-    sortType: 'Newest',
+    sortType: (localStorage.getItem("SortType") ? localStorage.getItem('SortType') : 'Newest'),
     currentChart: null as (ChartType | null)
     currentChart: null as (ChartType | null)
   }
   }
 
 
-  componentDidMount() {
-    if (localStorage.getItem("SortType")) {
-      this.setState({ sortType: localStorage.getItem("SortType") });
-    }
-  }
-
   componentDidUpdate(prevProps: PropsType) {
   componentDidUpdate(prevProps: PropsType) {
     localStorage.setItem("SortType", this.state.sortType);
     localStorage.setItem("SortType", this.state.sortType);
     // Reset namespace filter and close expanded chart on cluster change
     // Reset namespace filter and close expanded chart on cluster change
     if (prevProps.currentCluster !== this.props.currentCluster) {
     if (prevProps.currentCluster !== this.props.currentCluster) {
-      this.setState({ namespace: 'default', sortType: 'Newest', currentChart: null });
+      this.setState({ namespace: 'default', sortType: (
+        localStorage.getItem("SortType") ? localStorage.getItem('SortType') : 'Newest'
+      ), currentChart: null });
     }
     }
   }
   }
 
 

+ 11 - 4
dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -59,7 +59,7 @@ export default class ChartList extends Component<PropsType, StateType> {
         } else if (this.props.sortType == "Oldest") {
         } else if (this.props.sortType == "Oldest") {
           charts.sort((a: any, b: any) => (Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)) ? 1 : -1);
           charts.sort((a: any, b: any) => (Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)) ? 1 : -1);
         } else if (this.props.sortType == "Alphabetical") {
         } else if (this.props.sortType == "Alphabetical") {
-          charts.sort((a: any, b: any) => (a.name > b.name) ? 1: -1);
+          charts.sort((a: any, b: any) => (a.name > b.name) ? 1 : -1);
         }
         }
         this.setState({ charts }, () => {
         this.setState({ charts }, () => {
           this.setState({ loading: false, error: false });
           this.setState({ loading: false, error: false });
@@ -71,6 +71,7 @@ export default class ChartList extends Component<PropsType, StateType> {
 
 
   setupWebsocket = (kind: string) => {
   setupWebsocket = (kind: string) => {
       let { currentCluster, currentProject } = this.context;
       let { currentCluster, currentProject } = this.context;
+      console.log(currentCluster)
       let protocol = process.env.NODE_ENV == 'production' ? 'wss' : 'ws';
       let protocol = process.env.NODE_ENV == 'production' ? 'wss' : 'ws';
       let ws = new WebSocket(`${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`);
       let ws = new WebSocket(`${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`);
       ws.onopen = () => {
       ws.onopen = () => {
@@ -234,16 +235,22 @@ export default class ChartList extends Component<PropsType, StateType> {
 ChartList.contextType = Context;
 ChartList.contextType = Context;
 
 
 const Placeholder = styled.div`
 const Placeholder = styled.div`
-  padding-top: 100px;
   width: 100%;
   width: 100%;
   display: flex;
   display: flex;
   justify-content: center;
   justify-content: center;
   align-items: center;
   align-items: center;
   color: #ffffff44;
   color: #ffffff44;
-  font-size: 14px;
+  background: #26282f;
+  border-radius: 5px;
+  height: 320px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  font-size: 13px;
 
 
   > i {
   > i {
-    font-size: 18px;
+    font-size: 16px;
     margin-right: 12px;
     margin-right: 12px;
   }
   }
 `;
 `;

+ 57 - 12
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -19,6 +19,7 @@ import ValuesWrapper from '../../../../components/values-form/ValuesWrapper';
 import ValuesForm from '../../../../components/values-form/ValuesForm';
 import ValuesForm from '../../../../components/values-form/ValuesForm';
 import SettingsSection from './SettingsSection';
 import SettingsSection from './SettingsSection';
 import ConfirmOverlay from '../../../../components/ConfirmOverlay';
 import ConfirmOverlay from '../../../../components/ConfirmOverlay';
+import Loading from '../../../../components/Loading';
 
 
 type PropsType = {
 type PropsType = {
   namespace: string,
   namespace: string,
@@ -45,6 +46,7 @@ type StateType = {
   websockets: Record<string, any>,
   websockets: Record<string, any>,
   url: string | null,
   url: string | null,
   showDeleteOverlay: boolean,
   showDeleteOverlay: boolean,
+  deleting: boolean,
 };
 };
 
 
 export default class ExpandedChart extends Component<PropsType, StateType> {
 export default class ExpandedChart extends Component<PropsType, StateType> {
@@ -64,6 +66,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     websockets : {} as Record<string, any>,
     websockets : {} as Record<string, any>,
     url: null as string | null,
     url: null as string | null,
     showDeleteOverlay: false,
     showDeleteOverlay: false,
+    deleting: false,
   }
   }
 
 
   // Retrieve full chart data (includes form and values)
   // Retrieve full chart data (includes form and values)
@@ -457,7 +460,6 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
         console.log(err)
         console.log(err)
       } else {
       } else {
         this.setState({ components: res.data.Objects });
         this.setState({ components: res.data.Objects });
-        console.log(res.data.Objects)
       }
       }
     });
     });
 
 
@@ -472,12 +474,19 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
         console.log(err);
         console.log(err);
         return
         return
       }
       }
-      console.log(res.data)
-      
+
+      if (res.data?.spec?.rules && res.data?.spec?.rules[0]?.host) {
+        this.setState({url: `https://${res.data?.spec?.rules[0]?.host}` })
+        return;
+      }
+
       if (res.data?.status?.loadBalancer?.ingress) {
       if (res.data?.status?.loadBalancer?.ingress) {
-        this.setState({url: `http://${res.data?.status?.loadBalancer?.ingress[0]?.hostname}` })
+        this.setState({ url: `http://${res.data?.status?.loadBalancer?.ingress[0]?.hostname}` })
+        return;
       }
       }
-    })
+    });
+
+    this.updateTabs();
   }
   }
 
 
   componentDidUpdate(prevProps: PropsType) {
   componentDidUpdate(prevProps: PropsType) {
@@ -490,8 +499,8 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
   componentWillUnmount() {
   componentWillUnmount() {
     if (this.state.websockets) {
     if (this.state.websockets) {
       this.state.websockets.forEach((ws: WebSocket) => {
       this.state.websockets.forEach((ws: WebSocket) => {
-        ws.close()
-      })
+        ws.close();
+      });
     }
     }
   }
   }
 
 
@@ -517,7 +526,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
       return (
       return (
         <Url>
         <Url>
           <Bolded>Internal URI:</Bolded>
           <Bolded>Internal URI:</Bolded>
-          {`${serviceName}.${serviceNamespace}.namespace.svc.cluster.local`}
+          {`${serviceName}.${serviceNamespace}.svc.cluster.local`}
         </Url>
         </Url>
       );
       );
     }
     }
@@ -526,12 +535,11 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
   handleUninstallChart = () => {
   handleUninstallChart = () => {
     let { currentProject, currentCluster } = this.context;
     let { currentProject, currentCluster } = this.context;
     let { currentChart } = this.props;
     let { currentChart } = this.props;
-    console.log('here', currentChart.namespace, StorageType.Secret)
+    this.setState({ deleting: true });
     api.uninstallTemplate('<token>', {
     api.uninstallTemplate('<token>', {
+    }, {
       namespace: currentChart.namespace,
       namespace: currentChart.namespace,
-      cluster_id: currentCluster.id,
       storage: StorageType.Secret,
       storage: StorageType.Secret,
-    }, {
       name: currentChart.name,
       name: currentChart.name,
       id: currentProject.id,
       id: currentProject.id,
       cluster_id: currentCluster.id,
       cluster_id: currentCluster.id,
@@ -539,11 +547,18 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
       if (err) {
       if (err) {
         console.log(err)
         console.log(err)
       } else {
       } else {
-        console.log('worked i guess');
+        this.setState({ showDeleteOverlay: false });
+        this.props.setCurrentChart(null);
       }
       }
     });
     });
   }
   }
 
 
+  renderDeleteOverlay = () => {
+    if (this.state.deleting) {
+      return <DeleteOverlay><Loading /></DeleteOverlay>;
+    }
+  }
+
   render() {
   render() {
     let { currentChart, setCurrentChart } = this.props;
     let { currentChart, setCurrentChart } = this.props;
     let chart = currentChart;
     let chart = currentChart;
@@ -559,6 +574,8 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
             onYes={this.handleUninstallChart}
             onYes={this.handleUninstallChart}
             onNo={() => this.setState({ showDeleteOverlay: false })}
             onNo={() => this.setState({ showDeleteOverlay: false })}
           />
           />
+          {this.renderDeleteOverlay()}
+          
           <HeaderWrapper>
           <HeaderWrapper>
             <TitleSection>
             <TitleSection>
               <Title>
               <Title>
@@ -619,6 +636,34 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
 
 ExpandedChart.contextType = Context;
 ExpandedChart.contextType = Context;
 
 
+const DeleteOverlay = styled.div`
+  position: absolute;
+  top: 0px;
+  opacity: 100%;
+  left: 0px;
+  width: 100%;
+  height: 100%;
+  z-index: 999;
+  display: flex;
+  padding-bottom: 30px;
+  align-items: center;
+  justify-content: center;
+  font-family: 'Work Sans', sans-serif;
+  font-size: 18px;
+  font-weight: 500;
+  color: white;
+  flex-direction: column;
+  background: rgb(0,0,0,0.73);
+  opacity: 0;
+  animation: lindEnter 0.2s;
+  animation-fill-mode: forwards;
+
+  @keyframes lindEnter {
+    from { opacity: 0; }
+    to   { opacity: 1; }
+  }
+`;
+
 const Bolded = styled.div`
 const Bolded = styled.div`
   font-weight: 500;
   font-weight: 500;
   color: #ffffff44;
   color: #ffffff44;

+ 38 - 3
dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/GraphDisplay.tsx

@@ -6,6 +6,7 @@ import { ResourceType, NodeType, EdgeType, ChartType } from '../../../../../shar
 import Node from './Node';
 import Node from './Node';
 import Edge from './Edge';
 import Edge from './Edge';
 import InfoPanel from './InfoPanel';
 import InfoPanel from './InfoPanel';
+import ZoomPanel from './ZoomPanel';
 import SelectRegion from './SelectRegion';
 import SelectRegion from './SelectRegion';
 
 
 const zoomConstant = 0.01;
 const zoomConstant = 0.01;
@@ -41,6 +42,7 @@ type StateType = {
   preventBgDrag: boolean, // Prevent bg drag when moving selected with mouse down
   preventBgDrag: boolean, // Prevent bg drag when moving selected with mouse down
   relocateAllowed: boolean, // Suppress movement of selected when drawing select region
   relocateAllowed: boolean, // Suppress movement of selected when drawing select region
   scale: number,
   scale: number,
+  btnZooming: boolean,
   showKindLabels: boolean,
   showKindLabels: boolean,
   isExpanded: boolean,
   isExpanded: boolean,
   currentNode: NodeType | null,
   currentNode: NodeType | null,
@@ -73,6 +75,7 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
     preventBgDrag: false,
     preventBgDrag: false,
     relocateAllowed: false,
     relocateAllowed: false,
     scale: 0.5,
     scale: 0.5,
+    btnZooming: false,
     showKindLabels: true,
     showKindLabels: true,
     isExpanded: false,
     isExpanded: false,
     currentNode: null as (NodeType | null),
     currentNode: null as (NodeType | null),
@@ -344,6 +347,7 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
 
 
   // Handle pan XOR zoom (two-finger gestures count as onWheel)
   // Handle pan XOR zoom (two-finger gestures count as onWheel)
   handleWheel = (e: any) => {
   handleWheel = (e: any) => {
+    this.setState({ btnZooming: false });
 
 
     // Prevent nav gestures if mouse is over InfoPanel or ButtonSection
     // Prevent nav gestures if mouse is over InfoPanel or ButtonSection
     if (!this.state.suppressDisplay) {
     if (!this.state.suppressDisplay) {
@@ -363,6 +367,14 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
     }
     }
   };
   };
 
 
+  btnZoomIn = () => {
+    this.setState({ scale: 1.24, btnZooming: true});
+  }
+
+  btnZoomOut = () => {
+    this.setState({ scale: 0.76, btnZooming: true });
+  }
+
   toggleExpanded = () => {
   toggleExpanded = () => {
     this.setState({ isExpanded: !this.state.isExpanded }, () => {
     this.setState({ isExpanded: !this.state.isExpanded }, () => {
       this.props.setSidebar(!this.state.isExpanded);
       this.props.setSidebar(!this.state.isExpanded);
@@ -385,8 +397,21 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
   renderNodes = () => {
   renderNodes = () => {
     let { activeIds, originX, originY, cursorX, cursorY, scale, panX, panY, anchorX, anchorY, relocateAllowed } = this.state;
     let { activeIds, originX, originY, cursorX, cursorY, scale, panX, panY, anchorX, anchorY, relocateAllowed } = this.state;
 
 
-    return this.state.nodes.map((node: NodeType, i: number) => {
+    let minX = 0;
+    let maxX = 0;
+    let minY = 0;
+    let maxY = 0;
+    this.state.nodes.map((node: NodeType, i: number) => { 
+      if (node.x < minX) 
+      minX = (node.x < minX) ? node.x : minX;
+      maxX = (node.x > maxX) ? node.x : maxX;
+      minY = (node.y < minY) ? node.y : minY;
+      maxY = (node.y > maxY) ? node.y : maxY;
+    });
+    let midX = (minX + maxX)/2;
+    let midY = (minY + maxY)/2;
 
 
+    return this.state.nodes.map((node: NodeType, i: number) => {
       // Update position if not highlighting and active
       // Update position if not highlighting and active
       if (activeIds.includes(node.id) && relocateAllowed && !anchorX && !anchorY) {
       if (activeIds.includes(node.id) && relocateAllowed && !anchorX && !anchorY) {
         node.x = cursorX + node.toCursorX;
         node.x = cursorX + node.toCursorX;
@@ -401,8 +426,14 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
 
 
       // Apply cursor-centered zoom
       // Apply cursor-centered zoom
       if (this.state.scale !== 1) {
       if (this.state.scale !== 1) {
-        node.x = cursorX + scale * (node.x - cursorX);
-        node.y = cursorY + scale * (node.y - cursorY);
+        if (!this.state.btnZooming) {
+          node.x = cursorX + scale * (node.x - cursorX);
+          node.y = cursorY + scale * (node.y - cursorY);
+        } else {
+          console.log('hi')
+          node.x = midX + scale * (node.x - midX);
+          node.y = midY + scale * (node.y - midY);
+        }
       }
       }
 
 
       // Apply pan 
       // Apply pan 
@@ -510,6 +541,10 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
           isExpanded={this.state.isExpanded}
           isExpanded={this.state.isExpanded}
           showRevisions={this.props.showRevisions}
           showRevisions={this.props.showRevisions}
         />
         />
+        <ZoomPanel
+          btnZoomIn={this.btnZoomIn}
+          btnZoomOut={this.btnZoomOut}
+        />
       </StyledGraphDisplay>
       </StyledGraphDisplay>
     );
     );
   }
   }

+ 95 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/ZoomPanel.tsx

@@ -0,0 +1,95 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+type PropsType = {
+    btnZoomIn: () => void,
+    btnZoomOut: () => void,
+};
+
+type StateType = {
+  wrapperHeight: number
+};
+
+export default class ZoomPanel extends Component<PropsType, StateType> {
+  state = {
+    wrapperHeight: 0
+  }
+
+  wrapperRef: any = React.createRef();
+
+  componentDidMount() {
+    this.setState({ wrapperHeight: this.wrapperRef.offsetHeight });
+  }
+
+  renderContents = () => {
+    return (
+      <Div>
+        <IconWrapper onClick={this.props.btnZoomIn}>
+          <i className="material-icons">add</i>
+        </IconWrapper>
+        <ZoomBreaker />
+        <IconWrapper onClick={this.props.btnZoomOut}>
+          <i className="material-icons">remove</i>
+        </IconWrapper>
+      </Div>
+    )
+  }
+
+  render() {
+    return (
+      <StyledZoomer>
+        {this.renderContents()}
+      </StyledZoomer>
+    );
+  }
+}
+
+const Div = styled.div`
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: space-between;
+  height: calc(100% - 7px);
+`;
+
+const IconWrapper = styled.div`
+  width: 25px;
+  height: 25px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-top: -4px;
+  margin-bottom: -4px;
+  cursor: pointer;
+
+  > i {
+    font-size: 16px;
+    color: #ffffff;
+  }
+`;
+
+const StyledZoomer = styled.div`
+  position: absolute;
+  left: 15px;
+  bottom: 15px;
+  color: #ffffff;
+  height: 64px;
+  width: 36px;
+  background: #34373Cdf;
+  border-radius: 3px;
+  padding-left: 11px;
+  display: inline-block;
+  z-index: 999;
+  padding-top: 7px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  padding-right: 11px;
+  cursor: default;
+`;
+
+const ZoomBreaker = styled.div`
+  background: #ffffff20;
+  height: 1px;
+  width: 22px;
+`;

+ 9 - 5
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx

@@ -10,6 +10,7 @@ type PropsType = {
   selectedPod: any,
   selectedPod: any,
   selectPod: Function,
   selectPod: Function,
   isLast?: boolean,
   isLast?: boolean,
+  isFirst?: boolean,
 };
 };
 
 
 type StateType = {
 type StateType = {
@@ -26,7 +27,7 @@ export default class ControllerTab extends Component<PropsType, StateType> {
 
 
   componentDidMount() {
   componentDidMount() {
     let { currentCluster, currentProject, setCurrentError } = this.context;
     let { currentCluster, currentProject, setCurrentError } = this.context;
-    let { controller } = this.props;
+    let { controller, selectPod, isFirst } = this.props;
 
 
     let selectors = [] as string[];
     let selectors = [] as string[];
     let ml = controller?.spec?.selector?.matchLabels || controller?.spec?.selector;
     let ml = controller?.spec?.selector?.matchLabels || controller?.spec?.selector;
@@ -61,6 +62,10 @@ export default class ControllerTab extends Component<PropsType, StateType> {
       });
       });
       
       
       this.setState({ pods, raw: res.data });
       this.setState({ pods, raw: res.data });
+      
+      if (isFirst) {
+        selectPod(res.data[0])
+      }
     })
     })
   }
   }
 
 
@@ -80,7 +85,7 @@ export default class ControllerTab extends Component<PropsType, StateType> {
   }
   }
 
 
   getPodStatus = (status: any) => {
   getPodStatus = (status: any) => {
-    if (status?.phase == 'Pending' && status?.containerStatuses) {
+    if (status?.phase == 'Pending' && status?.containerStatuses !== undefined) {
       return status.containerStatuses[0].state.waiting.reason
       return status.containerStatuses[0].state.waiting.reason
       // return 'waiting'
       // return 'waiting'
     }
     }
@@ -104,17 +109,16 @@ export default class ControllerTab extends Component<PropsType, StateType> {
   }
   }
 
 
   render() {
   render() {
-    let { controller, selectedPod, isLast, selectPod } = this.props;
-    console.log(controller)
+    let { controller, selectedPod, isLast, selectPod, isFirst } = this.props;
     let [available, total] = this.getAvailability(controller.kind, controller);
     let [available, total] = this.getAvailability(controller.kind, controller);
     let status = (available == total) ? 'running' : 'waiting'
     let status = (available == total) ? 'running' : 'waiting'
-    console.log('state', this.state)
     return (
     return (
       <ResourceTab
       <ResourceTab
         label={controller.kind}
         label={controller.kind}
         name={controller.metadata.name}
         name={controller.metadata.name}
         status={{ label: status, available, total }}
         status={{ label: status, available, total }}
         isLast={isLast}
         isLast={isLast}
+        expanded={isFirst}
       >
       >
         {
         {
           this.state.raw.map((pod, i) => {
           this.state.raw.map((pod, i) => {

+ 119 - 28
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx

@@ -8,20 +8,28 @@ type PropsType = {
 
 
 type StateType = {
 type StateType = {
   logs: string[],
   logs: string[],
-  ws: any
+  ws: any,
+  scroll: boolean,
 };
 };
 
 
 export default class Logs extends Component<PropsType, StateType> {
 export default class Logs extends Component<PropsType, StateType> {
   
   
   state = {
   state = {
     logs: [] as string[],
     logs: [] as string[],
-    ws : null as any
+    ws : null as any,
+    scroll: true,
   }
   }
 
 
+  ws = null as any;
   scrollRef = React.createRef<HTMLDivElement>()
   scrollRef = React.createRef<HTMLDivElement>()
+  parentRef = React.createRef<HTMLDivElement>()
 
 
-  scrollToBottom = () => {
-    this.scrollRef.current.scrollTop = this.scrollRef.current.scrollHeight
+  scrollToBottom = (smooth: boolean) => {
+    if (smooth) {
+      this.parentRef.current.lastElementChild.scrollIntoView({ behavior: "smooth" })
+    } else {
+      this.parentRef.current.lastElementChild.scrollIntoView({ behavior: "auto" })
+    }
   }
   }
 
 
   renderLogs = () => {
   renderLogs = () => {
@@ -37,44 +45,78 @@ export default class Logs extends Component<PropsType, StateType> {
     })
     })
   }
   }
 
 
-  componentDidMount() {
+  setupWebsocket = () => {  
     let { currentCluster, currentProject } = this.context;
     let { currentCluster, currentProject } = this.context;
     let { selectedPod } = this.props;
     let { selectedPod } = this.props;
     if (!selectedPod.metadata?.name) return
     if (!selectedPod.metadata?.name) return
     let protocol = process.env.NODE_ENV == 'production' ? 'wss' : 'ws'
     let protocol = process.env.NODE_ENV == 'production' ? 'wss' : 'ws'
-    let ws = new WebSocket(`${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/k8s/${selectedPod?.metadata?.namespace}/pod/${selectedPod?.metadata?.name}/logs?cluster_id=${currentCluster.id}&service_account_id=${currentCluster.service_account_id}`)
-    
-    this.setState({ ws }, () => {
-      if (!this.state.ws) return;
-  
-      this.state.ws.onopen = () => {
-        console.log('connected to websocket')
-      }
-  
-      this.state.ws.onmessage = (evt: MessageEvent) => {
-        this.setState({ logs: [...this.state.logs, evt.data] }, () => {
-          this.scrollToBottom()
-        })
-      }
-  
-      this.state.ws.onerror = (err: ErrorEvent) => {
-        console.log(err)
-      }
-    })
+    this.ws = new WebSocket(`${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/k8s/${selectedPod?.metadata?.namespace}/pod/${selectedPod?.metadata?.name}/logs?cluster_id=${currentCluster.id}&service_account_id=${currentCluster.service_account_id}`)
+
+    this.ws.onopen = () => {
+      console.log('connected to websocket')
+    }
+
+    this.ws.onmessage = (evt: MessageEvent) => {
+      this.setState({ logs: [...this.state.logs, evt.data] }, () => {
+        if (this.state.scroll && this.state.logs.length >50) {
+          this.scrollToBottom(false)
+        }
+      })
+    }
+
+    this.ws.onerror = (err: ErrorEvent) => {
+      console.log("websocket error:", err)
+    }
+
+    this.ws.onclose = () => {
+      console.log("closing pod logs")
+    }
+  }
+
+  refreshLogs = () => {
+    if (this.ws) {
+      this.ws.close();
+      this.ws = null;
+      this.setState({logs: []})
+      this.setupWebsocket();
+    }
+  }
+
+  componentDidMount() {
+    this.setupWebsocket()
+    this.scrollToBottom(false);
   }
   }
 
 
   componentWillUnmount() {
   componentWillUnmount() {
-    if (this.state.ws) {
-      this.state.ws.close()
+    console.log('log unmount')
+    if (this.ws) {
+      this.ws.close()
     }
     }
   }
   }
 
 
   render() {
   render() {
     return (
     return (
-      <LogStream ref={this.scrollRef}>
-        <Wrapper>
+      <LogStream>
+        <Wrapper ref={this.parentRef}>
           {this.renderLogs()}
           {this.renderLogs()}
+          <div ref={this.scrollRef} />
         </Wrapper>
         </Wrapper>
+        <Options>
+          <Scroll onClick={()=> {
+            this.setState({scroll: !this.state.scroll}, () => {
+              if (this.state.scroll) {
+                this.scrollToBottom(true)
+              }
+            }); 
+          }}>
+            <input type="checkbox" checked={this.state.scroll} onChange={() => {}}/>
+            Scroll to Bottom
+          </Scroll>
+          <Refresh onClick={() => {this.refreshLogs()}}>
+            <i className="material-icons">autorenew</i>
+            Refresh
+          </Refresh>
+        </Options>
       </LogStream>
       </LogStream>
     );
     );
   }
   }
@@ -82,6 +124,54 @@ export default class Logs extends Component<PropsType, StateType> {
 
 
 Logs.contextType = Context;
 Logs.contextType = Context;
 
 
+const Scroll = styled.div`
+  align-items: center;
+  display: flex;
+  cursor: pointer;
+  width: 145px;
+  height: 100%;
+
+  :hover {
+    background: #2468d6;
+  }
+
+  > input {
+    width; 18px;
+    margin-left: 10px;
+    margin-right: 6px;
+    pointer-events: none;
+  }
+`
+
+const Refresh = styled.div`
+  display: flex;
+  align-items: center;
+  width: 87px;
+  user-select: none;
+  cursor: pointer;
+  height: 100%;
+
+  > i {
+    margin-left: 6px;
+    font-size: 17px;
+    margin-right: 6px;
+  }
+
+  :hover {
+    background: #2468d6;
+  }
+`
+
+const Options = styled.div`
+  width: 100%;
+  height: 25px;
+  background: #397ae3;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+`
+
 const Wrapper = styled.div`
 const Wrapper = styled.div`
   width: 100%;
   width: 100%;
   height: 100%;
   height: 100%;
@@ -91,6 +181,7 @@ const Wrapper = styled.div`
 
 
 const LogStream = styled.div`
 const LogStream = styled.div`
   display: flex;
   display: flex;
+  flex-direction: column;
   flex: 1;
   flex: 1;
   float: right;
   float: right;
   height: 100%;
   height: 100%;

+ 1 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx

@@ -51,6 +51,7 @@ export default class StatusSection extends Component<PropsType, StateType> {
           selectPod={this.selectPod.bind(this)}
           selectPod={this.selectPod.bind(this)}
           controller={c}
           controller={c}
           isLast={i === this.state.controllers.length - 1}
           isLast={i === this.state.controllers.length - 1}
+          isFirst={i === 0}
         />
         />
       )
       )
     })
     })

+ 102 - 105
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -1,68 +1,111 @@
+import { render } from '@testing-library/react';
 import React, { Component } from 'react';
 import React, { Component } from 'react';
 import styled from 'styled-components';
 import styled from 'styled-components';
-import gradient from '../../../assets/gradient.jpg';
 
 
+import gradient from '../../../assets/gradient.jpg';
 import { Context } from '../../../shared/Context';
 import { Context } from '../../../shared/Context';
-import StatusPlaceholderContainer from './StatusPlaceholderContainer';
+import { InfraType } from '../../../shared/types';
+import api from '../../../shared/api';
+
+import ProvisionerSettings from '../provisioner/ProvisionerSettings';
 
 
 type PropsType = {
 type PropsType = {
   setCurrentView: (x: string) => void,
   setCurrentView: (x: string) => void,
+  projectId: number | null,
 };
 };
 
 
 type StateType = {
 type StateType = {
+  infras: InfraType[],
 };
 };
 
 
 export default class Dashboard extends Component<PropsType, StateType> {
 export default class Dashboard extends Component<PropsType, StateType> {
-  renderDashboardIcon = () => {
-    let { currentProject } = this.context;
-    return (
-      <DashboardIcon>
-        <DashboardImage src={gradient} />
-        <Overlay>{currentProject && currentProject.name[0].toUpperCase()}</Overlay>
-      </DashboardIcon>
-    );
+  state = {
+    infras: [] as InfraType[],
   }
   }
 
 
-  renderContents = () => {
-    let { currentProject } = this.context;
-    if (currentProject) {
-      return (
-        <div>
-          <TitleSection>
-            {this.renderDashboardIcon()}
-            <Title>{currentProject && currentProject.name}</Title>
-            <i
-              className="material-icons"
-              onClick={() => this.context.setCurrentModal('UpdateProjectModal', { 
-                currentProject: currentProject,
-                setCurrentView: this.props.setCurrentView,
-              })}
-            >
-              more_vert
-          </i>
-          </TitleSection>
-
-          <InfoSection>
-            <TopRow>
-              <InfoLabel>
-                <i className="material-icons">info</i> Info
-            </InfoLabel>
-            </TopRow>
-            <Description>Project overview for {currentProject && currentProject.name}.</Description>
-          </InfoSection>
-
-          <LineBreak />
-
-          <StatusPlaceholderContainer setCurrentView={this.props.setCurrentView} />
-        </div>
-      );
+  refreshInfras = () => {
+    if (this.props.projectId) {
+      api.getInfra('<token>', {}, { 
+        project_id: this.props.projectId,
+      }, (err: any, res: any) => {
+        if (err) {
+          console.log(err);
+          return;
+        } 
+        this.setState({ infras: res.data });
+      });
     }
     }
   }
   }
+  
+  componentDidMount() {
+    this.refreshInfras();
+  }
+
+  componentDidUpdate(prevProps: PropsType) {
+    if (this.props.projectId && prevProps.projectId !== this.props.projectId) {
+      this.refreshInfras();
+    }
+  }
+
+  onShowProjectSettings = () => {
+    let { currentProject, setCurrentModal } = this.context;
+    let { setCurrentView } = this.props;
+    setCurrentModal('UpdateProjectModal', { 
+      currentProject: currentProject,
+      setCurrentView: setCurrentView,
+    });
+  }
 
 
   render() {
   render() {
+    let { currentProject, currentCluster } = this.context;
+    let { setCurrentView } = this.props;
+    let { infras } = this.state;
+    let { onShowProjectSettings } = this;
     return (
     return (
       <>
       <>
-        {this.renderContents()}
+        {currentProject && (
+          <DashboardWrapper>
+            <TitleSection>
+            <DashboardIcon>
+              <DashboardImage src={gradient} />
+              <Overlay>
+                {currentProject && currentProject.name[0].toUpperCase()}
+              </Overlay>
+            </DashboardIcon>
+              <Title>{currentProject && currentProject.name}</Title>
+              <i
+                className="material-icons"
+                onClick={onShowProjectSettings}
+              >
+                more_vert
+              </i>
+            </TitleSection>
+
+            <InfoSection>
+              <TopRow>
+                <InfoLabel>
+                  <i className="material-icons">info</i> Info
+              </InfoLabel>
+              </TopRow>
+              <Description>
+                Project overview for {currentProject && currentProject.name}.
+              </Description>
+            </InfoSection>
+
+            <LineBreak />
+
+            {!currentCluster && (
+              <Banner>
+                <i className="material-icons">error_outline</i>
+                This project currently has no clusters connected.
+              </Banner>
+            )}
+            <ProvisionerSettings 
+              setCurrentView={setCurrentView} 
+              infras={infras}
+            />
+          </DashboardWrapper>
+        )}
       </>
       </>
     );
     );
   }
   }
@@ -70,20 +113,24 @@ export default class Dashboard extends Component<PropsType, StateType> {
 
 
 Dashboard.contextType = Context;
 Dashboard.contextType = Context;
 
 
-const Placeholder = styled.div`
+const DashboardWrapper = styled.div`
+  padding-bottom: 100px;
+`;
+
+const Banner = styled.div`
+  height: 40px;
   width: 100%;
   width: 100%;
-  height: calc(100vh - 380px);
-  margin-top: 30px;
+  margin: 10px 0 30px;
+  font-size: 13px;
   display: flex;
   display: flex;
-  padding-bottom: 20px;
-  align-items: center;
-  justify-content: center;
-  color: #aaaabb;
   border-radius: 5px;
   border-radius: 5px;
-  text-align: center;
-  font-size: 13px;
-  background: #ffffff08;
-  font-family: 'Work Sans', sans-serif;
+  padding-left: 15px;
+  align-items: center;
+  background: #616FEEcc;
+  > i {
+    margin-right: 10px;
+    font-size: 18px;
+  }
 `;
 `;
 
 
 const TopRow = styled.div`
 const TopRow = styled.div`
@@ -119,56 +166,6 @@ const InfoSection = styled.div`
   margin-bottom: 35px;
   margin-bottom: 35px;
 `;
 `;
 
 
-const Button = styled.div`
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-between;
-  font-size: 13px;
-  cursor: pointer;
-  font-family: 'Work Sans', sans-serif;
-  border-radius: 20px;
-  color: white;
-  height: 30px;
-  padding: 0px 8px;
-  padding-bottom: 1px;
-  margin-right: 10px;
-  font-weight: 500;
-  padding-right: 15px;
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-  box-shadow: 0 5px 8px 0px #00000010;
-  cursor: not-allowed;
-
-  background: ${(props: { disabled: boolean }) => props.disabled ? '#aaaabbee' :'#616FEEcc'};
-  :hover {
-    background: ${(props: { disabled: boolean }) => props.disabled ? '' : '#505edddd'};
-  }
-
-  > i {
-    color: white;
-    width: 18px;
-    height: 18px;
-    font-size: 12px;
-    border-radius: 20px;
-    display: flex;
-    align-items: center;
-    margin-right: 5px;
-    justify-content: center;
-  }
-`;
-
-const ButtonAlt = styled(Button)`
-  min-width: 150px;
-  max-width: 150px;
-  background: #7A838Fdd;
-
-  :hover {
-    background: #69727eee;
-  }
-`;
-
 const LineBreak = styled.div`
 const LineBreak = styled.div`
   width: calc(100% - 0px);
   width: calc(100% - 0px);
   height: 2px;
   height: 2px;

+ 42 - 2
dashboard/src/main/home/integrations/IntegrationList.tsx

@@ -8,6 +8,7 @@ import api from '../../../shared/api';
 type PropsType = {
 type PropsType = {
   setCurrent: (x: any) => void,
   setCurrent: (x: any) => void,
   integrations: string[],
   integrations: string[],
+  titles?: string[],
   isCategory?: boolean
   isCategory?: boolean
 };
 };
 
 
@@ -16,8 +17,32 @@ type StateType = {
 
 
 export default class IntegrationList extends Component<PropsType, StateType> {
 export default class IntegrationList extends Component<PropsType, StateType> {
   renderContents = () => {
   renderContents = () => {
-    let { integrations, setCurrent, isCategory } = this.props;
-    if (integrations && integrations.length > 0) {
+    let { integrations, titles, setCurrent, isCategory } = this.props;
+    if (titles && titles.length > 0) {
+      return integrations.map((integration: string, i: number) => {
+        let icon = integrationList[integration] && integrationList[integration].icon;
+        let subtitle = integrationList[integration] && integrationList[integration].label;
+        let label = titles[i];
+        let disabled = integration === 'repo' || integration === 'kubernetes';
+        return (
+          <Integration
+            key={i}
+            onClick={() => disabled ? null : setCurrent(integration)}
+            isCategory={isCategory}
+            disabled={disabled}
+          >
+            <Flex>
+              <Icon src={icon && icon} />
+              <Description>
+                <Label>{label}</Label>
+                <Subtitle>{subtitle}</Subtitle>
+              </Description>
+            </Flex>
+            <i className="material-icons">{isCategory ? 'launch' : 'more_vert'}</i>
+          </Integration>
+        );
+      });
+    } else if (integrations && integrations.length > 0) {
       return integrations.map((integration: string, i: number) => {
       return integrations.map((integration: string, i: number) => {
         let icon = integrationList[integration] && integrationList[integration].icon;
         let icon = integrationList[integration] && integrationList[integration].icon;
         let label = integrationList[integration] && integrationList[integration].label;
         let label = integrationList[integration] && integrationList[integration].label;
@@ -90,12 +115,27 @@ const Integration = styled.div`
   }
   }
 `;
 `;
 
 
+const Description = styled.div`
+  display: flex;
+  flex-direction: column;
+  margin: 0;
+  padding: 0;
+`;
+
 const Label = styled.div`
 const Label = styled.div`
   color: #ffffff;
   color: #ffffff;
   font-size: 14px;
   font-size: 14px;
   font-weight: 500;
   font-weight: 500;
 `;
 `;
 
 
+const Subtitle = styled.div`
+  color: #aaaabb;
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  padding-top: 5px;
+`;
+
 const Icon = styled.img`
 const Icon = styled.img`
   width: 30px;
   width: 30px;
   margin-right: 18px;
   margin-right: 18px;

+ 29 - 3
dashboard/src/main/home/integrations/Integrations.tsx

@@ -16,6 +16,7 @@ type StateType = {
   currentCategory: string | null,
   currentCategory: string | null,
   currentIntegration: string | null,
   currentIntegration: string | null,
   currentOptions: any[],
   currentOptions: any[],
+  currentTitles: any[],
   currentIntegrationData: any[],
   currentIntegrationData: any[],
 };
 };
 
 
@@ -24,6 +25,7 @@ export default class Integrations extends Component<PropsType, StateType> {
     currentCategory: null as string | null,
     currentCategory: null as string | null,
     currentIntegration: null as string | null,
     currentIntegration: null as string | null,
     currentOptions: [] as any[],
     currentOptions: [] as any[],
+    currentTitles: [] as any[],
     currentIntegrationData: [] as any[],
     currentIntegrationData: [] as any[],
   }
   }
 
 
@@ -45,11 +47,25 @@ export default class Integrations extends Component<PropsType, StateType> {
           if (err) {
           if (err) {
             console.log(err);
             console.log(err);
           } else {
           } else {
+            // Sort res.data into service type and sort each service's registry alphabetically
+            let grouped: any = {}
+            let final: any = [];
+            for (let i = 0; i < res.data.length; i++) {
+              let p = res.data[i].service;
+              if (!grouped[p]) { grouped[p] = []; }
+              grouped[p].push(res.data[i]);
+            }
+            Object.values(grouped).forEach((val: any) => {
+              final = final.concat(val.sort((a: any, b: any) => (a.name > b.name) ? 1 : -1));
+            });
+
             let currentOptions = [] as string[];
             let currentOptions = [] as string[];
-            res.data.forEach((integration: any, i: number) => {
-              currentOptions.includes(integration.service) ? null : currentOptions.push(integration.service);
+            let currentTitles = [] as string[];
+            final.forEach((integration: any, i: number) => {
+              currentOptions.push(integration.service);
+              currentTitles.push(integration.name);
             });
             });
-            this.setState({ currentOptions, currentIntegrationData: res.data });
+            this.setState({ currentOptions, currentTitles, currentIntegrationData: res.data });
           }
           }
         });
         });
         break;
         break;
@@ -150,8 +166,11 @@ export default class Integrations extends Component<PropsType, StateType> {
             </Button>
             </Button>
           </TitleSectionAlt>
           </TitleSectionAlt>
 
 
+          <LineBreak />
+
           <IntegrationList
           <IntegrationList
             integrations={this.state.currentOptions}
             integrations={this.state.currentOptions}
+            titles={this.state.currentTitles}
             setCurrent={(x: string) => this.setState({ currentIntegration: x })}
             setCurrent={(x: string) => this.setState({ currentIntegration: x })}
           />
           />
         </div>
         </div>
@@ -293,4 +312,11 @@ const StyledIntegrations = styled.div`
   width: calc(90% - 150px);
   width: calc(90% - 150px);
   min-width: 300px;
   min-width: 300px;
   padding-top: 45px;
   padding-top: 45px;
+`;
+
+const LineBreak = styled.div`
+  width: calc(100% - 0px);
+  height: 2px;
+  background: #ffffff20;
+  margin: 32px 0px 24px;
 `;
 `;

+ 0 - 277
dashboard/src/main/home/modals/UpdateProjectModal.tsx

@@ -1,277 +0,0 @@
-import React, { Component } from 'react';
-import styled from 'styled-components';
-import close from '../../../assets/close.png';
-import gradient from '../../../assets/gradient.jpg';
-
-import api from '../../../shared/api';
-import { Context } from '../../../shared/Context';
-import { ClusterType } from '../../../shared/types';
-
-import SaveButton from '../../../components/SaveButton';
-import InputRow from '../../../components/values-form/InputRow';
-import ConfirmOverlay from '../../../components/ConfirmOverlay';
-
-type PropsType = {
-};
-
-type StateType = {
-  projectName: string,
-  status: string | null,
-  showDeleteOverlay: boolean
-};
-
-export default class UpdateProjectModal extends Component<PropsType, StateType> {
-  state = {
-    projectName: this.context.currentModalData.currentProject.name,
-    status: null as string | null,
-    showDeleteOverlay: false,
-  };
-
-  // Possibly consolidate into context (w/ ProjectSection + NewProject)
-  getProjects = () => {
-    let { user, currentProject, projects, setProjects } = this.context;
-    api.getProjects('<token>', {}, { id: user.userId }, (err: any, res: any) => {
-      if (err) {
-        console.log(err)
-      } else if (res.data) {
-        setProjects(res.data);
-        if (res.data.length > 0) {
-          this.context.setCurrentProject(res.data[0]);
-        } else {
-          this.context.currentModalData.setCurrentView('new-project');
-        }
-        this.context.setCurrentModal(null, null);
-      }
-    });
-  }
-  
-  // TODO: Handle update to unmounted component
-  handleDelete = () => {
-    let { currentProject } = this.context;
-    this.setState({ status: 'loading' });
-    api.deleteProject('<token>', {}, { id: currentProject.id }, (err: any, res: any) => {
-      if (err) {
-        this.setState({ status: 'error' });
-        // console.log(err)
-      } else {
-        this.getProjects();
-        this.setState({ status: 'successful', showDeleteOverlay: false });
-      }
-    });
-
-    // Loop through and delete infra of all clusters we've provisioned
-    api.getClusters('<token>', {}, { id: currentProject.id }, (err: any, res: any) => {
-      if (err) {
-        console.log(err);
-      } else {
-        res.data.forEach((cluster: ClusterType) => {
-
-          // Handle destroying infra we've provisioned
-          if (cluster.infra_id) {
-            console.log('destroying provisioned infra...', cluster.infra_id);
-            api.destroyCluster('<token>', { eks_name: cluster.name }, { 
-              project_id: currentProject.id,
-              infra_id: cluster.infra_id,
-            }, (err: any, res: any) => {
-              if (err) {
-                this.setState({ status: 'error' });
-                console.log(err)
-              } else {
-                console.log('destroyed provisioned infra:', cluster.infra_id);
-              }
-            });
-          }
-        });
-      }
-    });
-  }
-
-  render() {
-    return (
-      <StyledUpdateProjectModal>
-        <CloseButton onClick={() => {
-          this.context.setCurrentModal(null, null);
-        }}>
-          <CloseButtonImg src={close} />
-        </CloseButton>
-
-        <ModalTitle>Project Settings</ModalTitle>
-        <Subtitle>
-          Project name
-        </Subtitle>
-
-        <InputWrapper>
-          <ProjectIcon>
-            <ProjectImage src={gradient} />
-            <Letter>{this.state.projectName ? this.state.projectName[0].toUpperCase() : '-'}</Letter>
-          </ProjectIcon>
-          <InputRow
-            disabled={true}
-            type='string'
-            value={this.state.projectName}
-            setValue={(x: string) => this.setState({ projectName: x })}
-            placeholder='ex: perspective-vortex'
-            width='470px'
-          />
-        </InputWrapper>
-
-        <Warning highlight={true}>
-          ⚠️ Deletion may result in dangling resources. Please visit the AWS console to ensure that all resources have been removed.
-        </Warning>
-        <Help 
-          href='https://docs.getporter.dev/docs/getting-started-with-porter-on-aws#deleting-provisioned-resources'
-          target='_blank'
-        >
-          <i className="material-icons">help_outline</i> Help
-        </Help>
-
-        <SaveButton
-          text='Delete Project'
-          color='#b91133'
-          onClick={() => this.setState({ showDeleteOverlay: true })}
-          status={this.state.status}
-        />
-
-        <ConfirmOverlay
-          show={this.state.showDeleteOverlay}
-          message={`Are you sure you want to delete ${this.state.projectName}?`}
-          onYes={this.handleDelete}
-          onNo={() => this.setState({ showDeleteOverlay: false })}
-        />
-      </StyledUpdateProjectModal>
-    );
-  }
-}
-
-UpdateProjectModal.contextType = Context;
-
-const Help = styled.a`
-  position: absolute;
-  left: 31px;
-  bottom: 35px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: #ffffff55;
-  font-size: 13px;
-  :hover {
-    color: #ffffff;
-  }
-
-  > i {
-    margin-right: 9px;
-    font-size: 16px;
-  }
-`;
-
-const Warning = styled.div`
-  font-size: 13px;
-  display: flex;
-  border-radius: 3px;
-  width: calc(100%);
-  margin-top: 10px;
-  margin-left: 2px;
-  line-height: 1.4em;
-  align-items: center;
-  color: white;
-  > i {
-    margin-right: 10px;
-    font-size: 18px;
-  }
-  color: ${(props: { highlight: boolean, makeFlush?: boolean }) => props.highlight ? '#f5cb42' : ''};
-`;
-
-const Letter = styled.div`
-  height: 100%;
-  width: 100%;
-  position: absolute;
-  background: #00000028;
-  top: 0;
-  left: 0;
-  display: flex;
-  color: white;
-  align-items: center;
-  justify-content: center;
-`;
-
-const ProjectImage = styled.img`
-  width: 100%;
-  height: 100%;
-`;
-
-const ProjectIcon = styled.div`
-  width: 25px;
-  min-width: 25px;
-  height: 25px;
-  border-radius: 3px;
-  overflow: hidden;
-  position: relative;
-  margin-right: 10px;
-  font-weight: 400;
-  margin-top: 14px;
-`;
-
-const InputWrapper = styled.div`
-  display: flex;
-  align-items: center;
-`;
-
-const Subtitle = styled.div`
-  margin-top: 23px;
-  font-family: 'Work Sans', sans-serif;
-  font-size: 13px;
-  color: #aaaabb;
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-  margin-bottom: -10px;
-`;
-
-const ModalTitle = styled.div`
-  margin: 0px 0px 13px;
-  display: flex;
-  flex: 1;
-  font-family: 'Assistant';
-  font-size: 18px;
-  color: #ffffff;
-  user-select: none;
-  font-weight: 700;
-  align-items: center;
-  position: relative;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-`;
-
-const CloseButton = styled.div`
-  position: absolute;
-  display: block;
-  width: 40px;
-  height: 40px;
-  padding: 13px 0 12px 0;
-  z-index: 1;
-  text-align: center;
-  border-radius: 50%;
-  right: 15px;
-  top: 12px;
-  cursor: pointer;
-  :hover {
-    background-color: #ffffff11;
-  }
-`;
-
-const CloseButtonImg = styled.img`
-  width: 14px;
-  margin: 0 auto;
-`;
-
-const StyledUpdateProjectModal= styled.div`
-  width: 100%;
-  position: absolute;
-  left: 0;
-  top: 0;
-  height: 100%;
-  padding: 25px 32px;
-  overflow: hidden;
-  border-radius: 6px;
-  background: #202227;
-`;

+ 19 - 471
dashboard/src/main/home/new-project/NewProject.tsx

@@ -1,465 +1,40 @@
 import React, { Component } from 'react';
 import React, { Component } from 'react';
 import styled from 'styled-components';
 import styled from 'styled-components';
-import gradient from '../../../assets/gradient.jpg';
-import close from '../../../assets/close.png';
 
 
-import api from '../../../shared/api';
+import gradient from '../../../assets/gradient.jpg';
 import { Context } from '../../../shared/Context';
 import { Context } from '../../../shared/Context';
-import { integrationList } from '../../../shared/common';
-import { ProjectType } from '../../../shared/types';
+import { isAlphanumeric } from '../../../shared/common';
 
 
 import InputRow from '../../../components/values-form/InputRow';
 import InputRow from '../../../components/values-form/InputRow';
 import Helper from '../../../components/values-form/Helper';
 import Helper from '../../../components/values-form/Helper';
-import Heading from '../../../components/values-form/Heading';
-import SaveButton from '../../../components/SaveButton';
-
-const providers = ['aws', 'gcp', 'do',];
+import ProvisionerSettings from '../provisioner/ProvisionerSettings';
 
 
 type PropsType = {
 type PropsType = {
   setCurrentView: (x: string, data?: any) => void,
   setCurrentView: (x: string, data?: any) => void,
 };
 };
 
 
 type StateType = {
 type StateType = {
-  projectExists: boolean,
   projectName: string,
   projectName: string,
   selectedProvider: string | null,
   selectedProvider: string | null,
-  awsRegion: string | null,
-  awsAccessId: string | null,
-  awsSecretKey: string | null,
-  gcpRegion: string | null,
-  gcpProjectId: string | null,
-  gcpKeyData: string | null,
-  status: string | null,
 };
 };
 
 
 export default class NewProject extends Component<PropsType, StateType> {
 export default class NewProject extends Component<PropsType, StateType> {
   state = {
   state = {
-    projectExists: false,
     projectName: '',
     projectName: '',
     selectedProvider: null as string | null,
     selectedProvider: null as string | null,
-    awsRegion: '' as string | null,
-    awsAccessId: '' as string | null,
-    awsSecretKey: '' as string | null,
-    gcpRegion: '' as string | null,
-    gcpProjectId: '' as string | null,
-    gcpKeyData: '' as string | null,
-    status: null as string | null,
-  }
-
-  isAlphanumeric = (x: string) => {
-    let re = /^[a-z0-9-]+$/;
-    if (x.length == 0 || x.search(re) === -1) {
-      return false;
-    }
-    return true;
-  }
-
-  handleSelectProvider = (provider: string) => {
-    this.setState({ selectedProvider: provider });
-  }
-
-  renderProviderList = () => {
-    return providers.map((provider: string, i: number) => {
-      let providerInfo = integrationList[provider];
-      return (
-        <Block
-          key={i} 
-          onClick={() => this.handleSelectProvider(provider)}
-        >
-          <Icon src={providerInfo.icon} />
-          <BlockTitle>
-            {providerInfo.label}
-          </BlockTitle>
-          <BlockDescription>
-            Hosted in your own cloud.
-          </BlockDescription>
-        </Block>
-      )
-    });
-  }
-
-  // TODO: split this out into a separate component
-  renderProvisioners = () => {
-    if (this.state.selectedProvider === 'aws') {
-      return (
-        <FormSection>
-          <CloseButton onClick={() => {
-            this.setState({ selectedProvider: null });
-          }}>
-            <CloseButtonImg src={close} />
-          </CloseButton>
-          <DarkMatter />
-          <Heading>
-            AWS Credentials
-            <GuideButton href='https://docs.getporter.dev/docs/getting-started-with-porter-on-aws' target='_blank'>
-              <i className="material-icons-outlined">help</i> 
-              Guide
-            </GuideButton>
-          </Heading>
-          <InputRow
-            type='text'
-            value={this.state.awsRegion}
-            setValue={(x: string) => this.setState({ awsRegion: x })}
-            label='📍 AWS Region'
-            placeholder='ex: mars-north-12'
-            width='100%'
-            isRequired={true}
-          />
-          <InputRow
-            type='text'
-            value={this.state.awsAccessId}
-            setValue={(x: string) => this.setState({ awsAccessId: x })}
-            label='👤 AWS Access ID'
-            placeholder='ex: AKIAIOSFODNN7EXAMPLE'
-            width='100%'
-            isRequired={true}
-          />
-          <InputRow
-            type='password'
-            value={this.state.awsSecretKey}
-            setValue={(x: string) => this.setState({ awsSecretKey: x })}
-            label='🔒 AWS Secret Key'
-            placeholder='○ ○ ○ ○ ○ ○ ○ ○ ○'
-            width='100%'
-            isRequired={true}
-          />
-        </FormSection>
-      );
-    } else if (this.state.selectedProvider === 'gcp') {
-      return (
-        <FormSection>
-          <CloseButton onClick={() => {
-            this.setState({ selectedProvider: null });
-          }}>
-            <CloseButtonImg src={close} />
-          </CloseButton>
-          <DarkMatter />
-          <Heading>
-            GCP Credentials
-            <GuideButton href='https://docs.getporter.dev/docs/getting-started-with-porter-on-gcp' target='_blank'>
-              <i className="material-icons-outlined">help</i> 
-              Guide
-            </GuideButton>
-          </Heading>
-          <InputRow
-            type='text'
-            value={this.state.gcpRegion}
-            setValue={(x: string) => this.setState({ gcpRegion: x })}
-            label='📍 GCP Region'
-            placeholder='ex: us-central1-a'
-            width='100%'
-            isRequired={true}
-          />
-          <InputRow
-            type='text'
-            value={this.state.gcpProjectId}
-            setValue={(x: string) => this.setState({ gcpProjectId: x })}
-            label='🏷️ GCP Project ID'
-            placeholder='ex: pale-moon-24601'
-            width='100%'
-            isRequired={true}
-          />
-          <InputRow
-            type='password'
-            value={this.state.gcpKeyData}
-            setValue={(x: string) => this.setState({ gcpKeyData: x })}
-            label='🔒 GCP Key Data'
-            placeholder='○ ○ ○ ○ ○ ○ ○ ○ ○'
-            width='100%'
-            isRequired={true}
-          />
-        </FormSection>
-      );
-    } else if (this.state.selectedProvider === 'do') {
-      return (
-        <FormSection>
-          <CloseButton onClick={() => {
-            this.setState({ selectedProvider: null });
-          }}>
-            <CloseButtonImg src={close} />
-          </CloseButton>
-          <Flex>
-            DigitalOcean support is in closed beta. If you would like to run Porter in your own DO account, email <Highlight>contact@getporter.dev</Highlight>.
-          </Flex>
-        </FormSection>
-      );
-    }
-
-    return (
-      <BlockList>
-        {this.renderProviderList()}
-      </BlockList>
-    );
-  }
-
-  renderHostingSection = () => {
-    if (this.state.selectedProvider === 'skipped') {
-      return (
-        <>
-          <Helper>Select your hosting backend:</Helper>
-          <Placeholder>
-            You can manually link to an existing cluster once this project has been created.
-          </Placeholder>
-          <Helper>
-            Don't have a Kubernetes cluster?
-            <Highlight onClick={() => this.setState({ selectedProvider: null })}>
-              Provision through Porter
-            </Highlight>
-          </Helper>
-        </>
-      )
-    }
-
-    return (
-      <>
-        <Helper>
-          Select your hosting backend: <Required>*</Required>
-        </Helper>
-        {this.renderProvisioners()}
-        <Helper>
-          Already have a Kubernetes cluster? 
-          <Highlight onClick={() => {
-            if (this.state.projectExists) {
-              this.props.setCurrentView('dashboard');
-            } else {
-              this.setState({ selectedProvider: 'skipped' });
-            }
-          }}>
-            Skip
-          </Highlight>
-        </Helper>
-      </>
-    )
-  }
-
-  validateForm = () => {
-    let { 
-      projectName,
-      selectedProvider, 
-      awsAccessId, 
-      awsSecretKey, 
-      awsRegion,
-      gcpRegion,
-      gcpKeyData,
-      gcpProjectId,
-    } = this.state;
-    if (!this.isAlphanumeric(projectName) || projectName === '') {
-      return false;
-    } else if (selectedProvider === 'aws') {
-      return awsAccessId !== '' && awsSecretKey !== '' && awsRegion !== '';
-    } else if (selectedProvider === 'gcp') {
-      return gcpRegion !== '' && gcpKeyData !== '' && gcpProjectId !== '';
-    } else if (selectedProvider === 'skipped') {
-      return true;
-    }
-    return false;
   }
   }
 
 
-  provisionECR = (proj: ProjectType, callback: (proj: ProjectType, ecr: any) => void) => {
-    let { awsAccessId, awsSecretKey, awsRegion } = this.state;
-
-    api.createAWSIntegration('<token>', {
-      aws_region: awsRegion,
-      aws_access_key_id: awsAccessId,
-      aws_secret_access_key: awsSecretKey,
-    }, { id: proj.id }, (err: any, res: any) => {
-      if (err) {
-        console.log(err);
-        return;
-      }
-
-      api.provisionECR('<token>', {
-        aws_integration_id: res.data.id,
-        ecr_name: `${proj.name}-registry`
-      }, {id: proj.id}, (err: any, ecr:any) => {
-        if (err) {
-          this.setState({ 
-            projectExists: true,
-            status: 'Please provide valid credentials.',
-          });
-          return;
-        }
-
-        callback(proj, ecr);
-      })
-      
-    });
-  }
-
-  provisionEKS = (proj: ProjectType, ecr: any) => {
-    let { awsAccessId, awsSecretKey, awsRegion } = this.state;
-    let clusterName = `${proj.name}-cluster`
-
-    api.createAWSIntegration('<token>', {
-      aws_region: awsRegion,
-      aws_access_key_id: awsAccessId,
-      aws_secret_access_key: awsSecretKey,
-      aws_cluster_id: clusterName,
-    }, { id: proj.id }, (err: any, res: any) => {
-      if (err) {
-        console.log(err);
-        return;
-      }
-
-      api.provisionEKS('<token>', {
-        aws_integration_id: res.data.id,
-        eks_name: clusterName,
-      }, { id: proj.id}, (err: any, eks: any) => {
-        if (err) {
-          this.setState({ 
-            projectExists: true,
-            status: 'Please provide valid credentials.',
-          });
-          return;
-        }
-
-        this.props.setCurrentView('provisioner', [
-          { infra_id: ecr?.data?.id, kind: ecr?.data?.kind },
-          { infra_id: eks?.data?.id, kind: eks?.data?.kind },
-        ]);
-      })
-    })
-  }
-
-  provisionGKE = (proj: ProjectType, id: number) => {
-    let clusterName = `${proj.name}-cluster`
-    console.log('provisioning gke...');
-    api.createGKE('<token>', {
-      gke_name: clusterName,
-      gcp_integration_id: id,
-    }, { project_id: proj.id }, (err: any, res: any) => {
-      if (err) {
-        console.log(err);
-      } else if (res?.data) {
-        
-        // TODO: set to provisioner
-        alert('success');
-      }
-    });
-  }
-
-  provisionGCR = (proj: ProjectType, id: number) => {
-    console.log('provisioning gcr...');
-    api.createGCR('<token>', {
-      gcp_integration_id: id,
-    }, { project_id: proj.id }, (err: any, res: any) => {
-      if (err) {
-        console.log(err);
-      } else if (res?.data) {
-        console.log('gcr provisioned with response: ', res.data);
-        this.provisionGKE(proj, id);
-      }
-    });
-  }
-
-  provisionGCP = (proj: ProjectType) => {
-    this.setState({ status: 'loading' });
-
-    let { gcpRegion, gcpKeyData, gcpProjectId } = this.state;
-    console.log('provisioning gcp...');
-    api.createGCPIntegration('<token>', {
-      gcp_region: gcpRegion,
-      gcp_key_data: gcpKeyData,
-      gcp_project_id: gcpProjectId,
-    }, { project_id: proj.id }, (err: any, res: any) => {
-      if (err) {
-        console.log(err);
-      } else if (res?.data) {
-        console.log('gcp provisioned with response: ', res.data);
-        let { id } = res.data;
-        this.provisionGCR(proj, id);
-      }
-    });
-  }
-
-  createProject = () => {
-    this.setState({ status: 'loading' });
-    api.createProject('<token>', {
-      name: this.state.projectName
-    }, {}, (err: any, res: any) => {
-      if (err) {
-        console.log(err);
-      } else {
-        let { user } = this.context;
-        api.getProjects('<token>', {}, { id: user.userId }, (err: any, res: any) => {
-          if (err) {
-            console.log(err)
-          } else if (res.data) {
-            this.context.setProjects(res.data);
-            if (res.data.length > 0) {
-              let proj = res.data.find((el: ProjectType) => el.name === this.state.projectName);
-              this.context.setCurrentProject(proj);
-              
-              if (this.state.selectedProvider === 'aws') {
-                this.provisionECR(proj, this.provisionEKS);
-              } else if (this.state.selectedProvider === 'gcp') { 
-                this.provisionGCP(proj);
-              } else {
-                this.props.setCurrentView('dashboard', null);
-              }
-            } 
-          }
-        });
-      }
-    });
-  }
-
-  createInfra = () => {
-    this.setState({ status: 'loading' });
-    let { user } = this.context;
-    api.getProjects('<token>', {}, { id: user.userId }, (err: any, res: any) => {
-      if (err) {
-        console.log(err)
-      } else if (res.data) {
-        this.context.setProjects(res.data);
-        if (res.data.length > 0) {
-          let proj = res.data.find((el: ProjectType) => el.name === this.state.projectName);
-          this.context.setCurrentProject(proj);
-          if (this.state.selectedProvider === 'aws') {
-            this.provisionECR(proj, this.provisionEKS)
-
-          } else {
-            this.props.setCurrentView('dashboard', null);
-          }
-        } 
-      }
-    });
-  }
-
-  renderHeaderSection = () => {
-    if (this.state.projectExists) {
-      return (
-        <>
-          <TitleSection>
-            <Title>Configure Hosting</Title>
-          </TitleSection>
-          <Helper>     
-            <Warning highlight={true} makeFlush={true}>
-              There was an issue configuring your cloud provider.
-            </Warning>
-          </Helper>
-          <Helper>     
-            You can refer to our docs for instructions on 
-            <Link 
-              href="https://docs.getporter.dev/docs/getting-started-with-porter-on-aws"
-              target="_blank"
-            >
-              creating AWS credentials for Porter
-            </Link>.
-          </Helper>
-          <br />
-        </>
-      );
-    }
-
+  render() {
+    let { setCurrentView } = this.props;
+    let { projectName } = this.state;
     return (
     return (
-      <>
+      <StyledNewProject>
         <TitleSection>
         <TitleSection>
           <Title>New Project</Title>
           <Title>New Project</Title>
         </TitleSection>
         </TitleSection>
         <Helper>
         <Helper>
           Project name
           Project name
-          <Warning highlight={!this.isAlphanumeric(this.state.projectName) && this.state.projectName !== ''}>
+          <Warning highlight={!isAlphanumeric(this.state.projectName) && this.state.projectName !== ''}>
             (lowercase letters, numbers, and "-" only)
             (lowercase letters, numbers, and "-" only)
           </Warning>
           </Warning>
           <Required>*</Required>
           <Required>*</Required>
@@ -477,43 +52,12 @@ export default class NewProject extends Component<PropsType, StateType> {
             width='470px'
             width='470px'
           />
           />
         </InputWrapper>
         </InputWrapper>
-      </>
-    );
-  }
-
-  renderButton = () => {
-    if (this.state.projectExists) {
-      return (
-        <SaveButton
-          text='Submit'
-          disabled={!this.validateForm()}
-          onClick={this.createInfra}
-          makeFlush={true}
-          helper='Note: Provisioning can take up to 15 minutes'
-          status={this.state.status}
+        <ProvisionerSettings 
+          isInNewProject={true}
+          setCurrentView={setCurrentView} 
+          projectName={projectName}
         />
         />
-      );
-    }
-
-    return (
-      <SaveButton
-        text='Create Project'
-        disabled={!this.validateForm()}
-        onClick={this.createProject}
-        makeFlush={true}
-        helper='Note: Provisioning can take up to 15 minutes'
-        status={this.state.status}
-      />
-    );
-  }
-  
-  render() {
-    let { selectedProvider } = this.state;
-    return (
-      <StyledNewProject height={selectedProvider === 'aws' || selectedProvider === 'gcp' ? '700px' : '600px'}>
-        {this.renderHeaderSection()}
-        {this.renderHostingSection()}
-        {this.renderButton()}
+        <Br />
       </StyledNewProject>
       </StyledNewProject>
     );
     );
   }
   }
@@ -521,6 +65,11 @@ export default class NewProject extends Component<PropsType, StateType> {
 
 
 NewProject.contextType = Context;
 NewProject.contextType = Context;
 
 
+const Br = styled.div`
+  width: 100%;
+  height: 100px;
+`;
+
 const Link = styled.a`
 const Link = styled.a`
   cursor: pointer;
   cursor: pointer;
   margin-left: 5px;
   margin-left: 5px;
@@ -796,8 +345,7 @@ const TitleSection = styled.div`
 const StyledNewProject = styled.div`
 const StyledNewProject = styled.div`
   width: calc(90% - 150px);
   width: calc(90% - 150px);
   min-width: 300px;
   min-width: 300px;
-  height: ${(props: { height: string }) => props.height};
   position: relative;
   position: relative;
   padding-top: 50px;
   padding-top: 50px;
-  margin-top: ${(props: { height: string }) => props.height === '600px' ? 'calc(50vh - 350px)' : 'calc(50vh - 400px)'};
+  margin-top: calc(50vh - 340px);
 `;
 `;

+ 388 - 0
dashboard/src/main/home/project-settings/InviteList.tsx

@@ -0,0 +1,388 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { InviteType } from '../../../shared/types';
+import Loading from '../../../components/Loading';
+import api from '../../../shared/api';
+import InputRow from '../../../components/values-form/InputRow';
+
+import { Context } from '../../../shared/Context';
+
+type PropsType = {
+}
+
+type StateType = {
+  loading: boolean,
+  invites: InviteType[],
+  email: string,
+  invalidEmail: boolean,
+}
+
+export default class InviteList extends Component<PropsType, StateType> {
+  state = {
+    loading: true,
+    invites: [] as InviteType[],
+    email: '',
+    invalidEmail: false,
+  }
+
+  componentDidMount() {
+    this.getInviteData();
+  }
+
+  getInviteData = () => {
+    let { currentProject } = this.context;
+    
+    this.setState({ loading: true })
+    api.getInvites('<token>', {}, {
+      id: currentProject.id
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else {
+        this.setState({ invites: res.data, loading: false }, () => {
+          for (let i = this.state.invites.length - 1; i >= 0; i--) {
+            if (this.state.invites[i].expired && !this.state.invites[i].accepted) {
+              api.deleteInvite('<token>', {}, {
+                id: currentProject.id, invId: this.state.invites[i].id
+              }, (err: any, res: any) => {
+                if (err) {
+                  console.log(`Error deleting invite: ${err}`);
+                } else {
+                  this.state.invites.splice(i, 1);
+                }
+              })
+            }
+          }
+        });
+      }
+    });
+  }
+
+  validateEmail = () => {
+    var regex = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
+    if (regex.test(this.state.email.toLowerCase())) {
+      this.setState({ invalidEmail: false });
+      this.createInvite();
+    } else {
+      this.setState({ invalidEmail: true });
+    }
+  }
+
+  createInvite = () => {
+    let { currentProject } = this.context;
+    api.createInvite('<token>', { email: this.state.email }, { id: currentProject.id }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else {
+        this.getInviteData();
+        this.setState({ email: '' });
+      }
+    })
+  }
+
+  deleteInvite = (index: number) => {
+    let { currentProject } = this.context;
+    api.deleteInvite('<token>', {}, {
+      id: currentProject.id, invId: this.state.invites[index].id
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else {
+        this.getInviteData();
+      }
+    })
+  }
+
+  replaceInvite = (index: number) => {
+    let { currentProject } = this.context;
+    api.createInvite('<token>', { email: this.state.invites[index].email }, { id: currentProject.id }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else {
+        api.deleteInvite('<token>', {}, {
+          id: currentProject.id, invId: this.state.invites[index].id
+        }, (err: any, res: any) => {
+          if (err) {
+            console.log(err);
+          } else {
+            this.getInviteData();
+          }
+        })
+      }
+    })
+  }
+
+  copyToClip = (index: number) => {
+    let { currentProject } = this.context;
+    navigator.clipboard.writeText(
+      `${process.env.API_SERVER}/api/projects/${currentProject.id}/invites/${this.state.invites[index].token}`
+    ).then(function() {
+    }, function() {
+      console.log("couldn't copy link to clipboard");
+    })
+  }
+
+  renderInvitations = () => {
+    let { currentProject } = this.context;
+    if (this.state.loading) {
+      return (
+        <Loading />
+      )
+    } else {
+      var invContent: any[] = [];
+      for (let i = 0; i < this.state.invites.length; i++) {
+        if (this.state.invites[i].accepted) {
+          invContent.push(
+            <Tr key={i}>
+              <MailTd isTop={i === 0}>
+                {this.state.invites[i].email}
+              </MailTd>
+              <LinkTd isTop={i === 0}>
+              </LinkTd>
+              <Td isTop={i === 0}>
+                <CopyButton
+                  onClick={() => this.deleteInvite(i)}
+                >
+                  Remove
+                </CopyButton>
+              </Td>
+            </Tr>
+          )
+        } else if (this.state.invites[i].expired) {
+          invContent.push(
+            <Tr key={i}>
+              <MailTd isTop={i === 0}>
+                {this.state.invites[i].email}
+              </MailTd>
+              <LinkTd isTop={i === 0}>
+                <Rower>
+                  <ShareLink
+                    disabled={true}
+                    type='string'
+                    placeholder='Link expired'
+                  />
+                  <CopyButton
+                    onClick={() => this.replaceInvite(i)}
+                  >
+                    Get New Link
+                  </CopyButton>
+                </Rower>
+              </LinkTd>
+              <Td isTop={i === 0}>
+                <CopyButton
+                  onClick={() => this.deleteInvite(i)}
+                >
+                  Delete Invite
+                </CopyButton>
+              </Td>
+            </Tr>
+          )
+        } else {
+          invContent.push(
+            <Tr key={i}>
+              <MailTd isTop={i === 0}>
+                {this.state.invites[i].email}
+              </MailTd>
+              <LinkTd isTop={i === 0}>
+                <Rower>
+                  <ShareLink
+                    disabled={true}
+                    type='string'
+                    value={`${process.env.API_SERVER}/api/projects/${currentProject.id}/invites/${this.state.invites[i].token}`}
+                    placeholder='Unable to retrieve link'
+                  />
+                  <CopyButton
+                    onClick={() => this.copyToClip(i)}
+                  >
+                    Copy Link
+                  </CopyButton>
+                </Rower>
+              </LinkTd>
+              <Td isTop={i === 0}>
+                <CopyButton
+                  onClick={() => this.deleteInvite(i)}
+                >
+                  Delete Invite
+                </CopyButton>
+              </Td>
+            </Tr>
+          )
+        }
+      }
+      return (
+        <>
+          <Subsubtitle>Collaborators</Subsubtitle>
+          {invContent.length > 0
+            ? <Table><tbody>{invContent}</tbody></Table>
+            : <BodyText>This project currently has no collaborators.</BodyText>
+          }
+        </>
+      )
+    }
+  }
+
+  render() {
+    return (
+      <>
+        <Subtitle>Manage Access</Subtitle>
+        <CreateInvite>
+          <InputRow
+            label='Invite Collaborators'
+            value={this.state.email}
+            type='text'
+            setValue={(x: string) => this.setState({ email: x })}
+            width='324px'
+            placeholder='ex. mrp@getporter.dev'
+          />
+          <InviteButton
+            onClick={() => this.validateEmail()}
+          >
+            Invite!
+          </InviteButton>
+        </CreateInvite>
+        {this.state.invalidEmail &&
+          <Invalid>
+            Invalid Email Address. Try Again.
+          </Invalid>
+        }
+        {this.renderInvitations()}
+      </>
+    )
+  }
+}
+
+InviteList.contextType = Context;
+
+const Subtitle = styled.div`
+  font-size: 18px;
+  font-weight: 700;
+  font-family: 'Work Sans', sans-serif;
+  color: #ffffff;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  margin-bottom: 24px;
+  margin-top: 32px;
+`;
+
+const Subsubtitle = styled.div`
+  font-size: 13px;
+  font-family: 'Work Sans', sans-serif;
+  color: #ffffff;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  margin-bottom: 12px;
+`;
+
+const BodyText = styled.div`
+  color: #ffffff66;
+  font-weight: 400;
+  font-size: 13px;
+`;
+
+const CopyButton = styled.div`
+  color: #ffffff;
+  font-weight: 400;
+  font-size: 13px;
+  margin-left: 12px;
+  float: right;
+  width: 128px;
+  padding-top: 7px;
+  padding-bottom: 6px;
+  border-radius: 5px;
+  border: 1px solid #ffffff20;
+  background-color: #ffffff10;
+  text-align: center;
+  overflow: hidden;
+  transition: all 0.1s ease-out;
+  :hover {
+    border: 1px solid #ffffff66;
+    background-color: #ffffff20;
+  }
+`;
+
+const InviteButton = styled(CopyButton)`
+  margin-bottom: 14px;
+`;
+
+const Rower = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: center;
+`;
+
+const CreateInvite = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: flex-end;
+  margin-top: -20px;
+  margin-bottom: 14px;
+`;
+
+const ShareLink = styled.input`
+  outline: none;
+  border: none;
+  font-size: 13px;
+  background: #ffffff11;
+  border: 1px solid #ffffff55;
+  width: 50%;
+  color: #74a5f7;
+  padding: 5px 10px;
+  height: 30px;
+  text-overflow: ellipsis;
+  border-radius: 3px;
+  ::placeholder,
+  ::-webkit-input-placeholder {
+    color: #fa0a26;
+    font-weight: 600;
+  }
+`;
+
+const Spacer = styled.div`
+  height: 24px;
+`;
+
+const Table = styled.table`
+  width: 100%;
+  border-spacing: 0px;
+  border: 1px solid #ffffff55;
+  border-radius: 5px;
+`;
+
+const Td = styled.td`
+  white-space: nowrap;
+  padding: 20px 0px;
+  border-top: ${(props: {isTop: boolean}) => (props.isTop ? 'none' : '1px solid #ffffff55')};
+  &:last-child {
+    padding-right: 16px;
+  }
+`;
+
+const Tr = styled.tr`
+`;
+
+const MailTd = styled(Td)`
+  padding-left: 16px;
+  max-width: 242px;
+  min-width: 242px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  color: #ffffff;
+  font-weight: 400;
+  font-size: 13px;
+`;
+
+const LinkTd = styled(Td)`
+  width: 100%;
+`;
+
+const Invalid = styled.div`
+  margin-top: -26px;
+  margin-bottom: 26px;
+  color: #fa0a26;
+  font-size: 13px;
+  font-family: 'Work Sans', sans-serif;
+`;

+ 177 - 0
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -0,0 +1,177 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import InviteList from './InviteList';
+
+import { Context } from '../../../shared/Context';
+
+type PropsType = {
+  setCurrentView: (x: string) => void,
+}
+
+type StateType = {
+  projectName: string,
+}
+
+export default class ProjectSettings extends Component<PropsType, StateType> {
+  state = {
+    projectName: '',
+  }
+
+  componentDidMount() {
+    let { currentProject, user } = this.context;
+    this.setState({ projectName: currentProject.name });
+  }
+
+  renderTitle = () => {
+    let { currentProject } = this.context;
+    if (currentProject) {
+      return (
+        <>
+          <TitleSection>
+            <Title>Project Settings</Title>
+          </TitleSection>
+          <LineBreak />
+        </>
+      );
+    }
+  }
+
+  renderDelete = () => {
+    let { currentProject } = this.context;
+    if (currentProject) {
+      return (
+        <>
+          <Subtitle>Other Settings</Subtitle>
+          <Rower>
+            <BodyText>
+              Delete this project: 
+            </BodyText>
+            <DeleteButton
+              onClick={() => this.context.setCurrentModal('UpdateProjectModal', { 
+                currentProject: currentProject,
+                setCurrentView: this.props.setCurrentView,
+              })}
+            >
+              Delete
+            </DeleteButton>
+          </Rower>
+        </>
+      )
+    }
+  }
+
+  renderContents = () => {
+    return (
+      <ContentHolder>
+          <InviteList />
+          {this.renderDelete()}
+      </ContentHolder>
+    )
+  }
+
+  render () {
+    return (
+      <StyledProjectSettings>
+        {this.renderTitle()}
+        {this.renderContents()}
+      </StyledProjectSettings>
+    );
+  }
+}
+
+ProjectSettings.contextType = Context;
+
+const Title = styled.div`
+  font-size: 24px;
+  font-weight: 600;
+  font-family: 'Work Sans', sans-serif;
+  color: #ffffff;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const TitleSection = styled.div`
+  margin-bottom: 20px;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  height: 40px;
+`;
+
+const StyledProjectSettings = styled.div`
+  width: calc(90% - 150px);
+  min-width: 300px;
+  padding-top: 45px;
+`;
+
+const LineBreak = styled.div`
+  width: calc(100% - 0px);
+  height: 2px;
+  background: #ffffff20;
+  margin: 10px 0px -20px;
+`;
+
+const Subtitle = styled.div`
+  font-size: 18px;
+  font-weight: 700;
+  font-family: 'Work Sans', sans-serif;
+  color: #ffffff;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  margin-bottom: 24px;
+  margin-top: 32px;
+`;
+
+const BodyText = styled.div`
+  color: #ffffff;
+  font-weight: 400;
+  font-size: 13px;
+`;
+
+const CopyButton = styled.div`
+  color: #ffffff;
+  font-weight: 400;
+  font-size: 13px;
+  margin-left: 12px;
+  float: right;
+  width: 128px;
+  padding-top: 8px;
+  padding-bottom: 8px;
+  border-radius: 5px;
+  border: 1px solid #ffffff20;
+  background-color: #ffffff10;
+  text-align: center;
+  overflow: hidden;
+  transition: all 0.1s ease-out;
+  :hover {
+    border: 1px solid #ffffff66;
+    background-color: #ffffff20;
+  }
+`;
+
+const DeleteButton = styled(CopyButton)`
+  background-color: #b91133;
+  border: none;
+  width: 88px;
+  margin-left: 20px;
+  :hover {
+    background-color: #b91133;
+    filter: brightness(120%);
+    border: none;
+  }
+`;
+
+const ContentHolder = styled.div`
+  min-width: 420px;
+  width: 100%;
+  margin-bottom: 55px;
+`;
+
+const Rower = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+`;

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

@@ -0,0 +1,381 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import close from '../../../assets/close.png';
+import { isAlphanumeric } from '../../../shared/common';
+import api from '../../../shared/api';
+import { Context } from '../../../shared/Context';
+import { ProjectType, InfraType } from '../../../shared/types';
+
+import InputRow from '../../../components/values-form/InputRow';
+import Helper from '../../../components/values-form/Helper';
+import Heading from '../../../components/values-form/Heading';
+import SaveButton from '../../../components/SaveButton';
+import CheckboxList from '../../../components/values-form/CheckboxList';
+
+type PropsType = {
+  setSelectedProvisioner: (x: string | null) => void,
+  handleError: () => void,
+  projectName: string,
+  setCurrentView: (x: string | null, data?: any) => void,
+  infras: InfraType[],
+};
+
+type StateType = {
+  awsRegion: string,
+  awsAccessId: string,
+  awsSecretKey: string,
+  selectedInfras: { value: string, label: string }[],
+  buttonStatus: string,
+};
+
+const provisionOptions = [
+  { value: 'ecr', label: 'Elastic Container Registry (ECR)' },
+  { value: 'eks', label: 'Elastic Kubernetes Service (EKS)' },
+];
+
+// TODO: Consolidate across forms w/ HOC
+export default class AWSFormSection extends Component<PropsType, StateType> {
+  state = {
+    awsRegion: '',
+    awsAccessId: '',
+    awsSecretKey: '',
+    selectedInfras: [...provisionOptions],
+    buttonStatus: '',
+  }
+
+  componentDidMount = () => {
+    let { infras } = this.props;
+    let { selectedInfras } = this.state;
+
+    if (infras) {
+      
+      // From the dashboard, only uncheck and disable if "creating" or "created"
+      let filtered = selectedInfras;
+      infras.forEach(
+        (infra: InfraType, i: number) => {
+          let { kind, status } = infra;
+          if (status === 'creating' || status === 'created') {
+            filtered = filtered.filter((item: any) => {
+              return item.value !== kind;
+            });
+          }
+        }
+      );
+      this.setState({ selectedInfras: filtered });
+    }
+  }
+
+  checkFormDisabled = () => {
+    let { 
+      awsRegion,
+      awsAccessId, 
+      awsSecretKey, 
+      selectedInfras,
+    } = this.state;
+    let { projectName } = this.props;
+    if (projectName || projectName === '') {
+      return (
+        !isAlphanumeric(projectName) 
+          || !(awsAccessId !== '' && awsSecretKey !== '' && awsRegion !== '')
+          || selectedInfras.length === 0
+      );
+    } else {
+      return (
+        !(awsAccessId !== '' && awsSecretKey !== '' && awsRegion !== '')
+          || selectedInfras.length === 0
+      );
+    }
+  }
+
+  // Step 1: Create a project
+  createProject = (callback?: any) => {
+    console.log('Creating project');
+    let { projectName, handleError } = this.props;
+    let { 
+      user, 
+      setProjects, 
+      setCurrentProject, 
+      currentProject 
+    } = this.context;
+
+    api.createProject('<token>', { name: projectName }, {
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+        handleError();
+        return;
+      } else {
+        let proj = res.data;
+
+        // Need to set project list for dropdown
+        // TODO: consolidate into ProjectSection (case on exists in list on set)
+        api.getProjects('<token>', {}, { 
+          id: user.userId 
+        }, (err: any, res: any) => {
+          if (err) {
+            console.log(err);
+            handleError();
+            return;
+          }
+          setProjects(res.data);
+          setCurrentProject(proj);
+          callback && callback();
+        });
+      }
+    });
+  }
+
+  provisionECR = (callback?: any) => {
+    console.log('Provisioning ECR')
+    let { awsAccessId, awsSecretKey, awsRegion } = this.state;
+    let { currentProject } = this.context;
+    let { handleError } = this.props;
+
+    api.createAWSIntegration('<token>', {
+      aws_region: awsRegion,
+      aws_access_key_id: awsAccessId,
+      aws_secret_access_key: awsSecretKey,
+    }, { id: currentProject.id }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+        handleError();
+        return;
+      }
+
+      api.provisionECR('<token>', {
+        aws_integration_id: res.data.id,
+        ecr_name: `${currentProject.name}-registry`
+      }, {id: currentProject.id}, (err: any, res: any) => {
+        if (err) {
+          console.log(err);
+          handleError();
+          return;
+        }
+        callback && callback();
+      })
+      
+    });
+  }
+
+  provisionEKS = () => {
+    console.log('Provisioning EKS');
+    let { setCurrentView, handleError } = this.props;
+    let { awsAccessId, awsSecretKey, awsRegion } = this.state;
+    let { currentProject } = this.context;
+
+    let clusterName = `${currentProject.name}-cluster`
+    api.createAWSIntegration('<token>', {
+      aws_region: awsRegion,
+      aws_access_key_id: awsAccessId,
+      aws_secret_access_key: awsSecretKey,
+      aws_cluster_id: clusterName,
+    }, { id: currentProject.id }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+        handleError();
+        return;
+      }
+      api.provisionEKS('<token>', {
+        aws_integration_id: res.data.id,
+        eks_name: clusterName,
+      }, { id: currentProject.id}, (err: any, eks: any) => {
+        if (err) {
+          console.log(err);
+          handleError();
+          return;
+        }
+        setCurrentView('provisioner');
+      })
+    })
+  }
+
+  // TODO: handle generically (with > 2 steps)
+  onCreateAWS = () => {
+    let { projectName, setCurrentView } = this.props;
+    let { selectedInfras } = this.state;
+
+    console.log(selectedInfras);
+    if (!projectName) {
+      console.log(selectedInfras)
+      if (selectedInfras.length === 2) {
+        // Case: project exists, provision ECR + EKS
+        this.provisionECR(this.provisionEKS);
+      } else if (selectedInfras[0].value === 'ecr') {
+        // Case: project exists, only provision ECR
+        this.provisionECR(() => setCurrentView('provisioner'));
+      } else {
+        // Case: project exists, only provision EKS
+        this.provisionEKS();
+      }
+    } else {
+      if (selectedInfras.length === 2) {
+        // Case: project DNE, provision ECR + EKS 
+        this.createProject(() => this.provisionECR(this.provisionEKS));
+      } else if (selectedInfras[0].value === 'ecr') {
+        // Case: project DNE, only provision ECR
+        this.createProject(() => this.provisionECR(() => {
+          setCurrentView('provisioner');
+        }));
+      } else {
+        // Case: project DNE, only provision EKS
+        this.createProject(this.provisionEKS);
+      }
+    }
+  }
+
+  render() {
+    let { setSelectedProvisioner } = this.props;
+    let {
+      awsRegion,
+      awsAccessId,
+      awsSecretKey,
+      selectedInfras,
+    } = this.state;
+
+    return (
+      <StyledAWSFormSection>
+        <FormSection>
+          <CloseButton onClick={() => setSelectedProvisioner(null)}>
+            <CloseButtonImg src={close} />
+          </CloseButton>
+          <Heading isAtTop={true}>
+            AWS Credentials
+            <GuideButton 
+              href='https://docs.getporter.dev/docs/getting-started-with-porter-on-aws' 
+              target='_blank'
+            >
+              <i className="material-icons-outlined">help</i> 
+              Guide
+            </GuideButton>
+          </Heading>
+          <InputRow
+            type='text'
+            value={awsRegion}
+            setValue={(x: string) => this.setState({ awsRegion: x })}
+            label='📍 AWS Region'
+            placeholder='ex: us-east-2'
+            width='100%'
+            isRequired={true}
+          />
+          <InputRow
+            type='text'
+            value={awsAccessId}
+            setValue={(x: string) => this.setState({ awsAccessId: x })}
+            label='👤 AWS Access ID'
+            placeholder='ex: AKIAIOSFODNN7EXAMPLE'
+            width='100%'
+            isRequired={true}
+          />
+          <InputRow
+            type='password'
+            value={awsSecretKey}
+            setValue={(x: string) => this.setState({ awsSecretKey: x })}
+            label='🔒 AWS Secret Key'
+            placeholder='○ ○ ○ ○ ○ ○ ○ ○ ○'
+            width='100%'
+            isRequired={true}
+          />
+          <Br />
+          <Heading>AWS Resources</Heading>
+          <Helper>Porter will provision the following AWS resources</Helper>
+          <CheckboxList
+            options={provisionOptions}
+            selected={selectedInfras}
+            setSelected={(x: { value: string, label: string }[]) => {
+              this.setState({ selectedInfras: x });
+            }}
+          />
+        </FormSection>
+        {this.props.children ? this.props.children : <Padding />}
+        <SaveButton
+          text='Submit'
+          disabled={this.checkFormDisabled()}
+          onClick={this.onCreateAWS}
+          makeFlush={true}
+          helper='Note: Provisioning can take up to 15 minutes'
+        />
+      </StyledAWSFormSection>
+    );
+  }
+}
+
+AWSFormSection.contextType = Context;
+
+const Padding = styled.div`
+  height: 15px;
+`;
+
+const Br = styled.div`
+  width: 100%;
+  height: 2px;
+`;
+
+const StyledAWSFormSection = styled.div`
+  position: relative;
+  padding-bottom: 35px;
+`;
+
+const FormSection = styled.div`
+  background: #ffffff11;
+  margin-top: 25px;
+  background: #26282f;
+  border-radius: 5px;
+  margin-bottom: 25px;
+  padding: 25px;
+  padding-bottom: 16px;
+  font-size: 13px;
+  animation: fadeIn 0.3s 0s;
+  position: relative;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  z-index: 1;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const GuideButton = styled.a`
+  display: flex;
+  align-items: center;
+  margin-left: 20px;
+  color: #aaaabb;
+  font-size: 13px;
+  margin-bottom: -1px;
+  border: 1px solid #aaaabb;
+  padding: 5px 10px;
+  padding-left: 6px;
+  border-radius: 5px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff11;
+    color: #ffffff;
+    border: 1px solid #ffffff;
+
+    > i {
+      color: #ffffff;
+    }
+  }
+
+  > i {
+    color: #aaaabb;
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;

+ 238 - 0
dashboard/src/main/home/provisioner/DOFormSection.tsx

@@ -0,0 +1,238 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import close from '../../../assets/close.png';
+import { isAlphanumeric } from '../../../shared/common';
+import api from '../../../shared/api';
+import { Context } from '../../../shared/Context';
+import { ProjectType, InfraType } from '../../../shared/types';
+
+import InputRow from '../../../components/values-form/InputRow';
+import Helper from '../../../components/values-form/Helper';
+import Heading from '../../../components/values-form/Heading';
+import SaveButton from '../../../components/SaveButton';
+import CheckboxList from '../../../components/values-form/CheckboxList';
+
+type PropsType = {
+  setSelectedProvisioner: (x: string | null) => void,
+  handleError: () => void,
+  projectName: string,
+  infras: InfraType[],
+};
+
+type StateType = {
+  selectedInfras: { value: string, label: string }[],
+};
+
+const provisionOptions = [
+  { value: 'docr', label: 'Digital Ocean Container Registry' },
+  { value: 'doks', label: 'Digital Ocean Kubernetes Service' },
+];
+
+// TODO: Consolidate across forms w/ HOC
+export default class DOFormSection extends Component<PropsType, StateType> {
+  state = {
+    selectedInfras: [...provisionOptions],
+  }
+
+  componentDidMount = () => {
+    let { infras } = this.props;
+    let { selectedInfras } = this.state;
+
+    if (infras) {
+      
+      // From the dashboard, only uncheck and disable if "creating" or "created"
+      let filtered = selectedInfras;
+      infras.forEach(
+        (infra: InfraType, i: number) => {
+          let { kind, status } = infra;
+          if (status === 'creating' || status === 'created') {
+            filtered = filtered.filter((item: any) => {
+              return item.value !== kind;
+            });
+          }
+        }
+      );
+      this.setState({ selectedInfras: filtered });
+    }
+  }
+
+  checkFormDisabled = () => {
+    let { 
+      selectedInfras,
+    } = this.state;
+    let { projectName } = this.props;
+    if (projectName || projectName === '') {
+      return !isAlphanumeric(projectName) || selectedInfras.length === 0;
+    } else {
+      return selectedInfras.length === 0;
+    }
+  }
+
+  // Step 1: Create a project
+  createProject = (callback?: any) => {
+    console.log('Creating project');
+    let { projectName, handleError } = this.props;
+    let { 
+      user, 
+      setProjects, 
+      setCurrentProject, 
+    } = this.context;
+
+    api.createProject('<token>', { name: projectName }, {
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+        handleError();
+        return;
+      } else {
+        let proj = res.data;
+
+        // Need to set project list for dropdown
+        // TODO: consolidate into ProjectSection (case on exists in list on set)
+        api.getProjects('<token>', {}, { 
+          id: user.userId 
+        }, (err: any, res: any) => {
+          if (err) {
+            console.log(err);
+            handleError();
+            return;
+          }
+          setProjects(res.data);
+          setCurrentProject(proj);
+          callback && callback();
+        });
+      }
+    });
+  }
+
+  // TODO: handle generically (with > 2 steps)
+  onCreateDO = () => {
+    let { projectName } = this.props;
+    let { selectedInfras } = this.state;
+    let { currentProject } = this.context;
+
+    if (!projectName) {
+      window.location.href = `/api/oauth/projects/${currentProject.id}/digitalocean`;
+    } else {
+      this.createProject(() => {
+        console.log('redirecting...')
+        window.location.href = `/api/oauth/projects/${currentProject.id}/digitalocean`;
+      });
+    }
+  }
+
+  render() {
+    let { setSelectedProvisioner } = this.props;
+    let {
+      selectedInfras,
+    } = this.state;
+
+    return (
+      <StyledAWSFormSection>
+        <FormSection>
+          <CloseButton onClick={() => setSelectedProvisioner(null)}>
+            <CloseButtonImg src={close} />
+          </CloseButton>
+          <Heading isAtTop={true}>DigitalOcean Resources</Heading>
+          <Helper>Porter will provision the following DigitalOcean resources</Helper>
+          <CheckboxList
+            options={provisionOptions}
+            selected={selectedInfras}
+            setSelected={(x: { value: string, label: string }[]) => {
+              this.setState({ selectedInfras: x });
+            }}
+          />
+        </FormSection>
+        {this.props.children ? this.props.children : <Padding />}
+        <SaveButton
+          text='Submit'
+          disabled={this.checkFormDisabled()}
+          onClick={this.onCreateDO}
+          makeFlush={true}
+          helper='Note: Provisioning can take up to 15 minutes'
+        />
+      </StyledAWSFormSection>
+    );
+  }
+}
+
+DOFormSection.contextType = Context;
+
+const Padding = styled.div`
+  height: 15px;
+`;
+
+const Br = styled.div`
+  width: 100%;
+  height: 2px;
+`;
+
+const StyledAWSFormSection = styled.div`
+  position: relative;
+  padding-bottom: 35px;
+`;
+
+const FormSection = styled.div`
+  background: #ffffff11;
+  margin-top: 25px;
+  background: #26282f;
+  border-radius: 5px;
+  margin-bottom: 25px;
+  padding: 25px;
+  padding-bottom: 16px;
+  font-size: 13px;
+  animation: fadeIn 0.3s 0s;
+  position: relative;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  z-index: 1;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const GuideButton = styled.a`
+  display: flex;
+  align-items: center;
+  margin-left: 20px;
+  color: #aaaabb;
+  font-size: 13px;
+  margin-bottom: -1px;
+  border: 1px solid #aaaabb;
+  padding: 5px 10px;
+  padding-left: 6px;
+  border-radius: 5px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff11;
+    color: #ffffff;
+    border: 1px solid #ffffff;
+
+    > i {
+      color: #ffffff;
+    }
+  }
+
+  > i {
+    color: #aaaabb;
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;

+ 101 - 0
dashboard/src/main/home/provisioner/ExistingClusterSection.tsx

@@ -0,0 +1,101 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import api from '../../../shared/api';
+import { ProjectType } from '../../../shared/types';
+import { isAlphanumeric } from '../../../shared/common';
+import { Context } from '../../../shared/Context';
+
+import SaveButton from '../../../components/SaveButton';
+import CheckboxList from '../../../components/values-form/CheckboxList';
+
+type PropsType = {
+  projectName: string,
+  setCurrentView: (x: string, data?: any) => void,
+};
+
+type StateType = {
+  buttonStatus: string,
+};
+
+export default class ExistingClusterSection extends Component<PropsType, StateType> {
+  state = {
+    buttonStatus: '',
+  }
+
+  onCreateProject = () => {
+    let { projectName, setCurrentView } = this.props;
+    let { user, setProjects, setCurrentProject } = this.context;
+
+    this.setState({ buttonStatus: 'loading' });
+    api.createProject('<token>', { name: projectName }, {
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else {
+        api.getProjects('<token>', {}, { 
+          id: user.userId 
+        }, (err: any, res: any) => {
+          if (err) {
+            console.log(err)
+          } else if (res.data) {
+            setProjects(res.data);
+            if (res.data.length > 0) {
+              let proj = res.data.find((el: ProjectType) => {
+                return el.name === projectName;
+              });
+              setCurrentProject(proj);
+              setCurrentView('dashboard', null);
+            } 
+          }
+        });
+      }
+    });
+  }
+
+  render() {
+    let { children, projectName } = this.props;
+    let { buttonStatus } = this.state;
+    return (
+      <StyledExistingClusterSection>
+        <Placeholder>
+          You can manually link to an existing cluster once this project has
+          been created.
+        </Placeholder>
+        {children ? children : <Padding />}
+        <SaveButton
+          text='Submit'
+          disabled={!isAlphanumeric(projectName)}
+          onClick={this.onCreateProject}
+          status={buttonStatus}
+          makeFlush={true}
+          helper='Note: Provisioning can take up to 15 minutes'
+        />
+      </StyledExistingClusterSection>
+    );
+  }
+}
+
+ExistingClusterSection.contextType = Context;
+
+const Padding = styled.div`
+  height: 15px;
+`;
+
+const StyledExistingClusterSection = styled.div`
+  position: relative;
+  padding-bottom: 35px;
+`;
+
+const Placeholder = styled.div`
+  margin-top: 25px;
+  background: #26282f;
+  margin-bottom: 27px;
+  border-radius: 5px;
+  height: 170px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  font-size: 13px;
+`;

+ 182 - 0
dashboard/src/main/home/provisioner/GCPFormSection.tsx

@@ -0,0 +1,182 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import close from '../../../assets/close.png';
+
+import InputRow from '../../../components/values-form/InputRow';
+import Helper from '../../../components/values-form/Helper';
+import Heading from '../../../components/values-form/Heading';
+import SaveButton from '../../../components/SaveButton';
+import CheckboxList from '../../../components/values-form/CheckboxList';
+
+type PropsType = {
+  setSelectedProvisioner: (x: string | null) => void,
+};
+
+type StateType = {
+  gcpRegion: string,
+  gcpProjectId: string,
+  gcpKeyData: string,
+  selectedInfras: { value: string, label: string }[],
+};
+
+const dummyOptions = [
+  { value: 'gcr', label: 'Google Container Registry (GCR)' },
+  { value: 'gke', label: 'Googke Kubernetes Engine (GKE)' },
+];
+
+export default class GCPFormSection extends Component<PropsType, StateType> {
+  state = {
+    gcpRegion: '',
+    gcpProjectId: '',
+    gcpKeyData: '',
+    selectedInfras: [] as { value: string, label: string }[],
+  }
+
+  render() {
+    let { setSelectedProvisioner } = this.props;
+    let {
+      gcpRegion,
+      gcpProjectId,
+      gcpKeyData,
+      selectedInfras,
+    } = this.state;
+
+    return (
+      <StyledGCPFormSection>
+        <FormSection>
+          <CloseButton onClick={() => setSelectedProvisioner(null)}>
+            <CloseButtonImg src={close} />
+          </CloseButton>
+          <Heading isAtTop={true}>
+            GCP Credentials
+            <GuideButton 
+              href='https://docs.getporter.dev/docs/getting-started-with-porter-on-gcp' 
+              target='_blank'
+            >
+              <i className="material-icons-outlined">help</i> 
+              Guide
+            </GuideButton>
+          </Heading>
+          <InputRow
+            type='text'
+            value={gcpRegion}
+            setValue={(x: string) => this.setState({ gcpRegion: x })}
+            label='📍 GCP Region'
+            placeholder='ex: us-central1-a'
+            width='100%'
+            isRequired={true}
+          />
+          <InputRow
+            type='text'
+            value={gcpProjectId}
+            setValue={(x: string) => this.setState({ gcpProjectId: x })}
+            label='🏷️ GCP Project ID'
+            placeholder='ex: pale-moon-24601'
+            width='100%'
+            isRequired={true}
+          />
+          <InputRow
+            type='password'
+            value={gcpKeyData}
+            setValue={(x: string) => this.setState({ gcpKeyData: x })}
+            label='🔒 GCP Key Data'
+            placeholder='○ ○ ○ ○ ○ ○ ○ ○ ○'
+            width='100%'
+            isRequired={true}
+          />
+          <Br />
+          <Heading>Resources</Heading>
+          <Helper>Porter will provision the following resources</Helper>
+          <CheckboxList
+            options={dummyOptions}
+            selected={selectedInfras}
+            setSelected={(x: { value: string, label: string }[]) => {
+              this.setState({ selectedInfras: x });
+            }}
+          />
+        </FormSection>
+        <SaveButton
+          text='Submit'
+          disabled={true}
+          onClick={() => console.log('oolala')}
+          makeFlush={true}
+          helper='Note: Provisioning can take up to 15 minutes'
+        />
+      </StyledGCPFormSection>
+    );
+  }
+}
+
+const Br = styled.div`
+  width: 100%;
+  height: 2px;
+`;
+
+const StyledGCPFormSection = styled.div`
+  position: relative;
+  padding-bottom: 70px;
+`;
+
+const FormSection = styled.div`
+  background: #ffffff11;
+  margin-top: 25px;
+  background: #26282f;
+  border-radius: 5px;
+  padding: 25px;
+  padding-bottom: 16px;
+  font-size: 13px;
+  animation: fadeIn 0.3s 0s;
+  position: relative;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  z-index: 1;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const GuideButton = styled.a`
+  display: flex;
+  align-items: center;
+  margin-left: 20px;
+  color: #aaaabb;
+  font-size: 13px;
+  margin-bottom: -1px;
+  border: 1px solid #aaaabb;
+  padding: 5px 10px;
+  padding-left: 6px;
+  border-radius: 5px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff11;
+    color: #ffffff;
+    border: 1px solid #ffffff;
+
+    > i {
+      color: #ffffff;
+    }
+  }
+
+  > i {
+    color: #aaaabb;
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;

+ 69 - 0
dashboard/src/main/home/provisioner/InfraStatuses.tsx

@@ -0,0 +1,69 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import loadingDots from '../../../assets/loading-dots.gif';
+import { InfraType } from '../../../shared/types';
+import { infraNames } from '../../../shared/common';
+
+type PropsType = {
+  infras: InfraType[],
+};
+
+type StateType = {
+};
+
+export default class InfraStatuses extends Component<PropsType, StateType> {
+  state = {
+  }
+
+  renderStatusIcon = (status: string) => {
+    if (status === 'created') {
+      return <StatusIcon>✓</StatusIcon>;
+    } else if (status === 'creating') {
+      return <StatusIcon><img src={loadingDots} /></StatusIcon>
+    } else if (status === 'error') {
+      return <StatusIcon color='#e3366d'>✗</StatusIcon>
+    }
+  }
+
+  render() {
+    return (
+      <StyledInfraStatuses>
+        {this.props.infras.map((infra: InfraType, i: number) => {
+          return (
+            <InfraRow>
+              {this.renderStatusIcon(infra.status)}
+              {infraNames[infra.kind]}
+            </InfraRow>
+          )
+        })}
+      </StyledInfraStatuses>
+    );
+  }
+}
+
+const StatusIcon = styled.div<{ color?: string }>`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 20px;
+  font-size: 16px;
+  color: ${props => props.color ? props.color : '#68c49c'};
+  margin-right: 10px;
+`;
+
+const InfraRow = styled.div`
+  width: 100%;
+  height: 25px;
+  padding-left: 2px;
+  margin-top: 10px;
+  font-size: 13px;
+  color: #aaaabb;
+  display: flex;
+  align-items: center;
+`;
+
+const StyledInfraStatuses = styled.div`
+  margin-top: 20px;
+  margin-bottom: 0;
+`;

+ 281 - 0
dashboard/src/main/home/provisioner/ProvisionerSettings.tsx

@@ -0,0 +1,281 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { Context } from '../../../shared/Context';
+import { integrationList } from '../../../shared/common';
+import { InfraType } from '../../../shared/types';
+
+import Helper from '../../../components/values-form/Helper';
+import AWSFormSection from './AWSFormSection';
+import GCPFormSection from './GCPFormSection';
+import DOFormSection from './DOFormSection';
+import SaveButton from '../../../components/SaveButton';
+import ExistingClusterSection from './ExistingClusterSection';
+
+type PropsType = {
+  setCurrentView: (x: string, data?: any) => void,
+  isInNewProject?: boolean,
+  projectName?: string,
+  infras?: InfraType[],
+};
+
+type StateType = {
+  selectedProvider: string | null,
+  infras: InfraType[],
+};
+
+const providers = ['aws', 'gcp', 'do',];
+
+export default class NewProject extends Component<PropsType, StateType> {
+  state = {
+    selectedProvider: null as string | null,
+    infras: [] as InfraType[],
+  }
+
+  // Handle any submission (pre-status) error
+  handleError = () => {
+    let { setCurrentView } = this.props;
+    let { setCurrentError } = this.context;
+    setCurrentView('dashboard');
+    this.setState({ selectedProvider: null });
+    setCurrentError('Provisioning failed. Check your credentials and try again.');
+  }
+
+  renderSelectedProvider = () => {
+    let { selectedProvider } = this.state;
+    let { projectName, setCurrentView, infras } = this.props;
+
+    let renderSkipHelper = () => {
+      return (
+        <>
+          {selectedProvider === 'skipped' 
+            ? (
+              <Helper>
+                Don't have a Kubernetes cluster?
+                <Highlight 
+                  onClick={() => this.setState({ selectedProvider: null })}
+                >
+                  Provision through Porter
+                </Highlight>
+              </Helper>
+            ) : (
+              <PositionWrapper selectedProvider={selectedProvider}>
+                <Helper>
+                  Already have a Kubernetes cluster? 
+                  <Highlight 
+                    onClick={() => this.setState({ 
+                      selectedProvider: 'skipped' 
+                    })}
+                  >
+                    Skip
+                  </Highlight>
+                </Helper>
+              </PositionWrapper>
+            )
+          }
+        </>
+      );
+    }
+
+    switch (selectedProvider) {
+      case 'aws':
+        return (
+          <AWSFormSection 
+            handleError={this.handleError}
+            projectName={projectName}
+            infras={infras}
+            setCurrentView={setCurrentView}
+            setSelectedProvisioner={(x: string | null) => {
+              this.setState({ selectedProvider: x });
+            }}
+          >
+            {renderSkipHelper()}
+          </AWSFormSection>
+        );
+      case 'gcp':
+        return (
+          <GCPFormSection 
+            setSelectedProvisioner={(x: string | null) => {
+              this.setState({ selectedProvider: x });
+            }}
+          />
+        );
+      case 'do':
+        return (
+          <DOFormSection 
+            handleError={this.handleError}
+            projectName={projectName}
+            infras={infras}
+            setSelectedProvisioner={(x: string | null) => {
+              this.setState({ selectedProvider: x });
+            }}
+          />
+        )
+      default:
+        return (
+          <ExistingClusterSection 
+            projectName={projectName}
+            setCurrentView={setCurrentView}
+          >
+            {renderSkipHelper()}
+          </ExistingClusterSection>
+        );
+    }
+  }
+  
+  render() {
+    let { selectedProvider } = this.state;
+    let { isInNewProject } = this.props;
+    return (
+      <StyledProvisionerSettings>
+        <Helper>
+          {isInNewProject 
+            ? <>Select your hosting backend:<Required>*</Required></>
+            : 'Need a cluster? Provision through Porter:'
+          }
+        </Helper>
+        {!selectedProvider ? (
+          <BlockList>
+            {providers.map((provider: string, i: number) => {
+              let providerInfo = integrationList[provider];
+              return (
+                <Block
+                  key={i} 
+                  onClick={() => {
+                    this.setState({ selectedProvider: provider });
+                  }}
+                >
+                  <Icon src={providerInfo.icon} />
+                  <BlockTitle>
+                    {providerInfo.label}
+                  </BlockTitle>
+                  <BlockDescription>
+                    Hosted in your own cloud.
+                  </BlockDescription>
+                </Block>
+              );
+            })}
+          </BlockList>
+        ) : (
+          <>{this.renderSelectedProvider()}</>
+        )}
+        {(isInNewProject && !selectedProvider) && (
+          <>
+            <Helper>
+              Already have a Kubernetes cluster? 
+              <Highlight 
+                onClick={() => this.setState({ selectedProvider: 'skipped' })}
+              >
+                Skip
+              </Highlight>
+            </Helper>
+            <Br />
+            <SaveButton
+              text='Submit'
+              disabled={true}
+              onClick={() => {}}
+              makeFlush={true}
+              helper='Note: Provisioning can take up to 15 minutes'
+            />
+          </>
+        )}
+      </StyledProvisionerSettings>
+    );
+  }
+}
+
+NewProject.contextType = Context;
+
+const Br = styled.div`
+  width: 100%;
+  height: 35px;
+`;
+
+const StyledProvisionerSettings = styled.div`
+  position: relative;
+`;
+
+const PositionWrapper = styled.div<{ selectedProvider: string | null}>`
+`;
+
+const Highlight = styled.div`
+  margin-left: 5px;
+  color: #8590ff;
+  cursor: pointer;
+`;
+
+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 Required = styled.div`
+  margin-left: 8px;
+  color: #fc4976;
+`;
+
+const Icon = styled.img<{ bw?: boolean }>`
+  height: 42px;
+  margin-top: 30px;
+  margin-bottom: 15px;
+  filter: ${props => props.bw ? 'grayscale(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;  
+`;
+
+const BlockTitle = styled.div`
+  margin-bottom: 12px;
+  width: 80%;
+  text-align: center;
+  font-size: 14px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const Block = styled.div<{ disabled?: boolean }>`
+  align-items: center;
+  user-select: none;
+  border-radius: 5px;
+  display: flex;
+  font-size: 13px;
+  overflow: hidden;
+  font-weight: 500;
+  padding: 3px 0px 5px;
+  flex-direction: column;
+  align-item: center;
+  justify-content: space-between;
+  height: 170px;
+  cursor: ${props => props.disabled ? '' : 'pointer'};
+  color: #ffffff;
+  position: relative;
+  background: #26282f;
+  box-shadow: 0 3px 5px 0px #00000022;
+  :hover {
+    background: ${props => props.disabled ? '' : '#ffffff11'};
+  }
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from { opacity: 0 }
+    to { opacity: 1 }
+  }
+`;

+ 99 - 67
dashboard/src/main/home/new-project/Provisioner.tsx → dashboard/src/main/home/provisioner/ProvisionerStatus.tsx

@@ -6,13 +6,13 @@ import { Context } from '../../../shared/Context';
 import ansiparse from '../../../shared/ansiparser'
 import ansiparse from '../../../shared/ansiparser'
 import loading from '../../../assets/loading.gif';
 import loading from '../../../assets/loading.gif';
 import warning from '../../../assets/warning.png';
 import warning from '../../../assets/warning.png';
+import { InfraType } from '../../../shared/types';
+import { filterOldInfras } from '../../../shared/common';
 
 
 import Helper from '../../../components/values-form/Helper';
 import Helper from '../../../components/values-form/Helper';
-import { eventNames } from 'process';
-import { inflateRaw, inflateRawSync } from 'zlib';
+import InfraStatuses from './InfraStatuses';
 
 
 type PropsType = {
 type PropsType = {
-  viewData: any,
   setCurrentView: (x: string) => void,
   setCurrentView: (x: string) => void,
 }
 }
 
 
@@ -23,8 +23,17 @@ type StateType = {
   maxStep : Record<string, number>,
   maxStep : Record<string, number>,
   currentStep: Record<string, number>,
   currentStep: Record<string, number>,
   triggerEnd: boolean,
   triggerEnd: boolean,
+  infras: InfraType[],
 };
 };
 
 
+const dummyInfras = [
+  { kind: 'ecr', status: 'creating', id: 5, project_id: 1 }, 
+  { kind: 'eks', status: 'error', id: 3, project_id: 1 },
+  { kind: 'eks', status: 'error', id: 1, project_id: 1 },
+  { kind: 'eks', status: 'error', id: 4, project_id: 1 },
+  { kind: 'ecr', status: 'created', id: 2, project_id: 1 },
+];
+
 export default class Provisioner extends Component<PropsType, StateType> {
 export default class Provisioner extends Component<PropsType, StateType> {
   state = {
   state = {
     error: false,
     error: false,
@@ -33,6 +42,44 @@ export default class Provisioner extends Component<PropsType, StateType> {
     maxStep: {} as Record<string, any>,
     maxStep: {} as Record<string, any>,
     currentStep: {} as Record<string, number>,
     currentStep: {} as Record<string, number>,
     triggerEnd: false,
     triggerEnd: false,
+    infras: [] as InfraType[],
+  }
+
+  componentDidMount() {
+    let { currentProject } = this.context;
+    let protocol = process.env.NODE_ENV == 'production' ? 'wss' : 'ws'
+
+    // Check if current project is provisioning
+    api.getInfra('<token>', {}, { 
+      project_id: currentProject.id 
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } 
+      let infras = filterOldInfras(res.data);
+      let error = false;
+      infras.forEach((infra: InfraType, i: number) => {
+        if (infra.status === 'error') {
+          error = true;
+        }
+      });
+
+      // Filter historical infras list for most current instances of each
+      let websockets = infras.map((infra: any) => {
+        let ws = new WebSocket(`${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/provision/${infra.kind}/${infra.infra_id}/logs`)
+        return this.setupWebsocket(ws, infra)
+      });
+  
+      this.setState({ error, infras, websockets, logs: ["Provisioning resources..."] });
+    });
+  }
+
+  componentWillUnmount() {
+    if (!this.state.websockets) { return; }
+
+    this.state.websockets.forEach((ws: any) => {
+      ws.close()
+    })
   }
   }
 
 
   scrollToBottom = () => {
   scrollToBottom = () => {
@@ -127,69 +174,19 @@ export default class Provisioner extends Component<PropsType, StateType> {
     return ws
     return ws
   }
   }
 
 
-  componentDidMount() {
-    let { currentProject } = this.context;
-    let protocol = process.env.NODE_ENV == 'production' ? 'wss' : 'ws'
-    let viewData = this.props.viewData || []
-
-    let websockets = viewData.map((infra: any) => {
-      let ws = new WebSocket(`${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/provision/${infra.kind}/${infra.infra_id}/logs`)
-      return this.setupWebsocket(ws, infra)
-    });
-
-    this.setState({ websockets, logs: ["Provisioning EKS cluster and ECR registry..."] });
-  }
-
-  componentWillUnmount() {
-    if (!this.state.websockets) { return; }
-
-    this.state.websockets.forEach((ws: any) => {
-      ws.close()
-    })
-  }
-
   scrollRef = React.createRef<HTMLDivElement>();
   scrollRef = React.createRef<HTMLDivElement>();
 
 
   renderLogs = () => {
   renderLogs = () => {
     return this.state.logs.map((log, i) => {
     return this.state.logs.map((log, i) => {
-      return <Log key={i}>{log}</Log>
+      return <Log key={i}>{log}</Log>;
     });
     });
   }
   }
 
 
-  renderHeadingSection = () => {
-    if (this.state.error) {
-      return (
-        <>
-          <TitleSection>
-            <Title><img src={warning} /> Provisioning Error</Title>
-          </TitleSection>
-
-          <Helper>
-            Porter encountered an error while provisioning.
-            <Link onClick={() => this.props.setCurrentView('dashboard')}>
-              Exit to dashboard
-            </Link> 
-            to try again with new credentials.
-          </Helper>
-        </>
-      );
-    }
-
-    return (
-      <>
-        <TitleSection>
-          <Title><img src={loading} /> Setting Up Porter</Title>
-        </TitleSection>
-        <Helper>
-          Porter is currently being provisioned to your AWS account:
-        </Helper>
-      </>
-    )
-  }
-
   onEnd = () => {
   onEnd = () => {
     let myInterval = setInterval(() => {
     let myInterval = setInterval(() => {
-      api.getClusters('<token>', {}, { id: this.context.currentProject.id }, (err: any, res: any) => {
+      api.getClusters('<token>', {}, { 
+        id: this.context.currentProject.id 
+      }, (err: any, res: any) => {
         if (err) {
         if (err) {
           console.log(err);
           console.log(err);
         } else if (res.data) {
         } else if (res.data) {
@@ -204,6 +201,9 @@ export default class Provisioner extends Component<PropsType, StateType> {
   }
   }
   
   
   render() {
   render() {
+    let { error, triggerEnd, infras } = this.state;
+    let { setCurrentView } = this.props;
+    
     let maxStep = 0;
     let maxStep = 0;
     let currentStep = 0;
     let currentStep = 0;
 
 
@@ -219,23 +219,55 @@ export default class Provisioner extends Component<PropsType, StateType> {
       }
       }
     }
     }
 
 
-    if (maxStep !== 0 && currentStep === maxStep && !this.state.triggerEnd) {
+    if (maxStep !== 0 && currentStep === maxStep && !triggerEnd) {
       this.onEnd()
       this.onEnd()
       this.setState({ triggerEnd: true });
       this.setState({ triggerEnd: true });
     }
     }
 
 
     return (
     return (
       <StyledProvisioner>
       <StyledProvisioner>
-        {this.renderHeadingSection()}
-
+        {error 
+          ? (
+            <>
+              <TitleSection>
+                <Title><img src={warning} /> Provisioning Error</Title>
+              </TitleSection>
+    
+              <Helper>
+                Porter encountered an error while provisioning.
+                <Link onClick={() => setCurrentView('dashboard')}>
+                  Exit to dashboard
+                </Link> 
+                to try again with new credentials.
+              </Helper>
+            </>
+          ) : (
+            <>
+              <TitleSection>
+                <Title><img src={loading} /> Setting Up Porter</Title>
+              </TitleSection>
+              <Helper>
+                Porter is currently provisioning resources in your cloud provider:
+              </Helper>
+            </>
+          )
+        }
+      
         <LoadingBar>
         <LoadingBar>
-          <Loaded progress={((currentStep / (maxStep == 0 ? 1 : maxStep)) * 100).toString() + '%'} />
+          <Loaded 
+            progress={
+              error ? (
+                '0%'
+              ) : (
+                (((currentStep / (maxStep == 0 ? 1 : maxStep)) * 100).toString() + '%')
+              )
+            }
+          />
         </LoadingBar>
         </LoadingBar>
+        <InfraStatuses infras={infras} />
 
 
         <LogStream ref={this.scrollRef}>
         <LogStream ref={this.scrollRef}>
-          <Wrapper>
-            {this.renderLogs()}
-          </Wrapper>
+          <Wrapper>{this.renderLogs()}</Wrapper>
         </LogStream>
         </LogStream>
 
 
         <Helper>
         <Helper>
@@ -273,7 +305,7 @@ const Log = styled.div`
 
 
 const LogStream = styled.div`
 const LogStream = styled.div`
   height: 300px;
   height: 300px;
-  margin-top: 30px;
+  margin-top: 20px;
   font-size: 13px;
   font-size: 13px;
   border: 2px solid #ffffff55;
   border: 2px solid #ffffff55;
   border-radius: 10px;
   border-radius: 10px;
@@ -292,8 +324,8 @@ const Message = styled.div`
   font-size: 13px;
   font-size: 13px;
 `;
 `;
 
 
-const Loaded = styled.div`
-  width: ${(props: { progress: string }) => props.progress};
+const Loaded = styled.div<{ progress: string }>`
+  width: ${props => props.progress};
   height: 100%;
   height: 100%;
   background: linear-gradient(to right, #4f8aff, #8e7dff, #4f8aff);
   background: linear-gradient(to right, #4f8aff, #8e7dff, #4f8aff);
   background-size: 400% 400%;
   background-size: 400% 400%;

+ 21 - 3
dashboard/src/main/home/sidebar/ClusterSection.tsx

@@ -49,14 +49,32 @@ export default class ClusterSection extends Component<PropsType, StateType> {
         this.props.setWelcome(true);
         this.props.setWelcome(true);
       } else {
       } else {
         this.props.setWelcome(false);
         this.props.setWelcome(false);
-        
         // TODO: handle uninitialized kubeconfig
         // TODO: handle uninitialized kubeconfig
         if (res.data) {
         if (res.data) {
+          console.log(res.data);
           let clusters = res.data;
           let clusters = res.data;
+          clusters.sort((a: any, b: any) => a.id - b.id);
           if (clusters.length > 0) {
           if (clusters.length > 0) {
             this.setState({ clusters });
             this.setState({ clusters });
-            setCurrentCluster(clusters[0]);
-          } else if (this.props.currentView !== 'provisioner') {
+            let saved = JSON.parse(localStorage.getItem('currentCluster'));
+            if (localStorage.getItem('currentCluster') !== 'null') {
+              setCurrentCluster(clusters[0]);
+              for (let i = 0; i < clusters.length; i++) {
+                if (clusters[i].id = saved.id 
+                  && clusters[i].project_id === saved.project_id 
+                  && clusters[i].name === saved.name
+                ) {
+                  setCurrentCluster(clusters[i]);
+                  break;
+                }
+              }
+            } else {
+              setCurrentCluster(clusters[0]);
+            }
+          } else if (
+            this.props.currentView !== 'provisioner'
+            && this.props.currentView !== 'new-project'
+          ) {
             this.setState({ clusters: [] });
             this.setState({ clusters: [] });
             setCurrentCluster(null);
             setCurrentCluster(null);
             this.props.setCurrentView('dashboard');
             this.props.setCurrentView('dashboard');

+ 2 - 0
dashboard/src/main/home/sidebar/Drawer.tsx

@@ -22,6 +22,8 @@ export default class Drawer extends Component<PropsType, StateType> {
     let { currentCluster, setCurrentCluster } = this.context;
     let { currentCluster, setCurrentCluster } = this.context;
 
 
     if (clusters.length > 0 && currentCluster) {
     if (clusters.length > 0 && currentCluster) {
+      clusters.sort((a, b) => a.id - b.id);
+      
       return clusters.map((cluster: ClusterType, i: number) => {
       return clusters.map((cluster: ClusterType, i: number) => {
         /*
         /*
         let active = this.context.activeProject &&
         let active = this.context.activeProject &&

+ 5 - 31
dashboard/src/main/home/sidebar/ProjectSection.tsx

@@ -2,13 +2,12 @@ import React, { Component } from 'react';
 import styled from 'styled-components';
 import styled from 'styled-components';
 import gradient from '../../../assets/gradient.jpg';
 import gradient from '../../../assets/gradient.jpg';
 
 
-import api from '../../../shared/api';
 import { Context } from '../../../shared/Context';
 import { Context } from '../../../shared/Context';
 import { ProjectType, InfraType } from '../../../shared/types';
 import { ProjectType, InfraType } from '../../../shared/types';
 
 
 type PropsType = {
 type PropsType = {
   currentProject: ProjectType,
   currentProject: ProjectType,
-  setCurrentView: (x: string, viewData?: any) => void,
+  setCurrentView: (x: string) => void,
   projects: ProjectType[],
   projects: ProjectType[],
 };
 };
 
 
@@ -21,40 +20,15 @@ export default class ProjectSection extends Component<PropsType, StateType> {
     expanded: false,
     expanded: false,
   };
   };
 
 
-  handleSelectProject = (project: ProjectType) => {
-    this.context.setCurrentProject(project);
-    
-    api.getInfra('<token>', {}, { project_id: project.id }, (err: any, res: any) => {
-      if (err) {
-        console.log(err);
-      } else if (res.data) {
-
-        let viewData = [] as any[]
-        res.data.forEach((el: InfraType) => {
-          if (el.status === 'creating') {
-            viewData.push({
-              infra_id: el.id,
-              kind: el.kind,
-            });
-          }
-        });
-
-        if (viewData.length > 0) {
-          this.props.setCurrentView('provisioner', viewData);
-        } else {
-          this.props.setCurrentView('dashboard');
-        }
-      }
-    });
-  }
-
   renderOptionList = () => {
   renderOptionList = () => {
+    let { setCurrentProject } = this.context;
+
     return this.props.projects.map((project: ProjectType, i: number) => {
     return this.props.projects.map((project: ProjectType, i: number) => {
       return (
       return (
         <Option
         <Option
           key={i}
           key={i}
           selected={project.name === this.props.currentProject.name}
           selected={project.name === this.props.currentProject.name}
-          onClick={() => this.handleSelectProject(project)}
+          onClick={() => setCurrentProject(project)}
         >
         >
           <ProjectIcon>
           <ProjectIcon>
             <ProjectImage src={gradient} />
             <ProjectImage src={gradient} />
@@ -170,7 +144,7 @@ const Option = styled.div`
   font-size: 13px;
   font-size: 13px;
   align-items: center;
   align-items: center;
   padding-left: 10px;
   padding-left: 10px;
-  cursor: ${(props: { selected: boolean, lastItem?: boolean }) => props.selected ? '' : 'pointer'};
+  cursor: pointer;
   padding-right: 10px;
   padding-right: 10px;
   background: ${(props: { selected: boolean, lastItem?: boolean }) => props.selected ? '#ffffff11' : ''};
   background: ${(props: { selected: boolean, lastItem?: boolean }) => props.selected ? '#ffffff11' : ''};
   :hover {
   :hover {

+ 1 - 1
dashboard/src/main/home/sidebar/ProjectSectionContainer.tsx

@@ -5,7 +5,7 @@ import { Context } from '../../../shared/Context';
 import ProjectSection from './ProjectSection';
 import ProjectSection from './ProjectSection';
 
 
 type PropsType = {
 type PropsType = {
-  setCurrentView: (x: string, viewData?: any) => void,
+  setCurrentView: (x: string) => void,
 };
 };
 
 
 type StateType = {
 type StateType = {

+ 42 - 32
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -3,17 +3,19 @@ import styled from 'styled-components';
 import category from '../../../assets/category.svg';
 import category from '../../../assets/category.svg';
 import integrations from '../../../assets/integrations.svg';
 import integrations from '../../../assets/integrations.svg';
 import filter from '../../../assets/filter.svg';
 import filter from '../../../assets/filter.svg';
+import settings from '../../../assets/settings.svg';
 
 
 import { Context } from '../../../shared/Context';
 import { Context } from '../../../shared/Context';
 
 
 import ClusterSection from './ClusterSection';
 import ClusterSection from './ClusterSection';
 import ProjectSectionContainer from './ProjectSectionContainer';
 import ProjectSectionContainer from './ProjectSectionContainer';
 import loading from '../../../assets/loading.gif';
 import loading from '../../../assets/loading.gif';
+import posthog from 'posthog-js';
 
 
 type PropsType = {
 type PropsType = {
   forceSidebar: boolean,
   forceSidebar: boolean,
   setWelcome: (x: boolean) => void,
   setWelcome: (x: boolean) => void,
-  setCurrentView: (x: string, viewData?: any) => void,
+  setCurrentView: (x: string) => void,
   currentView: string,
   currentView: string,
   forceRefreshClusters: boolean,
   forceRefreshClusters: boolean,
   setRefreshClusters: (x: boolean) => void,
   setRefreshClusters: (x: boolean) => void,
@@ -92,38 +94,46 @@ export default class Sidebar extends Component<PropsType, StateType> {
   };
   };
 
 
   renderProjectContents = () => {
   renderProjectContents = () => {
-    if (this.props.currentView === 'provisioner') {
-      return (
-        <ProjectPlaceholder>
-          <img src={loading} /> Creating . . .
-        </ProjectPlaceholder>
-      )
-    } else if (this.context.currentProject) {
+    let { currentView, setCurrentView } = this.props;
+    let { currentProject, setCurrentModal } = this.context;
+    if (currentProject) {
       return (
       return (
         <>
         <>
           <SidebarLabel>Home</SidebarLabel>
           <SidebarLabel>Home</SidebarLabel>
           <NavButton
           <NavButton
-            onClick={() => this.props.setCurrentView('dashboard')}
-            selected={this.props.currentView === 'dashboard'}
+            onClick={() => (currentView !== 'provisioner') && setCurrentView('dashboard')}
+            selected={currentView === 'dashboard' || currentView === 'provisioner'}
           >
           >
-            <img src={category} />
+            <Img src={category} />
             Dashboard
             Dashboard
           </NavButton>
           </NavButton>
           <NavButton
           <NavButton
-            onClick={() => this.props.setCurrentView('templates')}
-            selected={this.props.currentView === 'templates'}
+            onClick={() => setCurrentView('templates')}
+            selected={currentView === 'templates'}
           >
           >
-            <img src={filter} />
+            <Img src={filter} />
             Templates
             Templates
           </NavButton>
           </NavButton>
           <NavButton
           <NavButton
-            // onClick={() => this.props.setCurrentView('integrations')}
-            selected={this.props.currentView === 'integrations'}
-            onClick={() => this.context.setCurrentModal('IntegrationsInstructionsModal', {})}
+            selected={currentView === 'integrations'}
+            onClick={() => {
+              setCurrentModal('IntegrationsInstructionsModal', {})
+            }}
           >
           >
-            <img src={integrations} />
+            <Img src={integrations} />
             Integrations
             Integrations
           </NavButton>
           </NavButton>
+          {this.context.currentProject.roles.filter((obj: any) => {
+            return obj.user_id === this.context.user.userId;
+          })[0].kind === 'admin' &&
+            <NavButton
+              onClick={() => this.props.setCurrentView('project-settings')}
+              selected={this.props.currentView === 'project-settings'}
+            >
+              <Img enlarge={true} src={settings} />
+              Settings
+            </NavButton>
+          }
 
 
           <br />
           <br />
 
 
@@ -132,9 +142,9 @@ export default class Sidebar extends Component<PropsType, StateType> {
             forceCloseDrawer={this.state.forceCloseDrawer} 
             forceCloseDrawer={this.state.forceCloseDrawer} 
             releaseDrawer={() => this.setState({ forceCloseDrawer: false })}
             releaseDrawer={() => this.setState({ forceCloseDrawer: false })}
             setWelcome={this.props.setWelcome}
             setWelcome={this.props.setWelcome}
-            currentView={this.props.currentView}
-            setCurrentView={this.props.setCurrentView}
-            isSelected={this.props.currentView === 'cluster-dashboard'}
+            currentView={currentView}
+            setCurrentView={setCurrentView}
+            isSelected={currentView === 'cluster-dashboard'}
             forceRefreshClusters={this.props.forceRefreshClusters}
             forceRefreshClusters={this.props.forceRefreshClusters}
             setRefreshClusters={this.props.setRefreshClusters}
             setRefreshClusters={this.props.setRefreshClusters}
           />
           />
@@ -153,7 +163,7 @@ export default class Sidebar extends Component<PropsType, StateType> {
   // SidebarBg is separate to cover retracted drawer
   // SidebarBg is separate to cover retracted drawer
   render() {
   render() {
     return (
     return (
-      <div>
+      <>
         {this.renderPullTab()}
         {this.renderPullTab()}
         <StyledSidebar showSidebar={this.state.showSidebar}>
         <StyledSidebar showSidebar={this.state.showSidebar}>
           <SidebarBg />
           <SidebarBg />
@@ -174,7 +184,7 @@ export default class Sidebar extends Component<PropsType, StateType> {
 
 
           {this.renderProjectContents()}
           {this.renderProjectContents()}
         </StyledSidebar>
         </StyledSidebar>
-      </div>
+      </>
     );
     );
   }
   }
 }
 }
@@ -230,16 +240,16 @@ const NavButton = styled.div`
     left: 19px;
     left: 19px;
     top: 8px;
     top: 8px;
   }
   }
+`;
 
 
-  > img {
-    padding: 4px 4px;
-    height: 23px;
-    width: 23px;
-    border-radius: 3px;
-    position: absolute;
-    left: 20px;
-    top: 9px;
-  }
+const Img = styled.img<{ enlarge?: boolean }>`
+  padding: 4px 4px;
+  height: ${props => props.enlarge ? '27px' : '23px'};
+  width: ${props => props.enlarge ? '27px' : '23px'};
+  border-radius: 3px;
+  position: absolute;
+  left: ${props => props.enlarge ? '19px' : '20px'};
+  top: 9px;
 `;
 `;
 
 
 const BottomSection = styled.div`
 const BottomSection = styled.div`

+ 6 - 1
dashboard/src/main/home/templates/Templates.tsx

@@ -17,6 +17,7 @@ const tabOptions = [
 const hardcodedNames: any = {
 const hardcodedNames: any = {
   'postgresql': 'PostgreSQL',
   'postgresql': 'PostgreSQL',
   'docker': 'Docker',
   'docker': 'Docker',
+  'https-issuer': 'HTTPS Issuer'
 };
 };
 
 
 type PropsType = {
 type PropsType = {
@@ -45,7 +46,11 @@ export default class Templates extends Component<PropsType, StateType> {
       if (err) {
       if (err) {
         this.setState({ loading: false, error: true });
         this.setState({ loading: false, error: true });
       } else {
       } else {
-        this.setState({ porterTemplates: res.data, loading: false, error: false });
+        this.setState({ porterTemplates: res.data, error: false }, () => {
+          this.state.porterTemplates.sort((a, b) => (a.name > b.name) ? 1 : -1);
+          this.state.porterTemplates.sort((a,b) => (a.name === 'docker') ? -1 : (b.name === 'docker') ? 1 : 0);
+          this.setState({ loading: false });
+        });
       }
       }
     });
     });
   }
   }

+ 9 - 0
dashboard/src/main/home/templates/expanded-template/LaunchTemplate.tsx

@@ -55,9 +55,16 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
     let { currentCluster, currentProject } = this.context;
     let { currentCluster, currentProject } = this.context;
     let name = randomWords({ exactly: 3, join: '-' });
     let name = randomWords({ exactly: 3, join: '-' });
     this.setState({ saveValuesStatus: 'loading' });
     this.setState({ saveValuesStatus: 'loading' });
+
+    let values = {};
+    for (let key in wildcard) {
+      _.set(values, key, wildcard[key]);
+    }
+
     api.deployTemplate('<token>', {
     api.deployTemplate('<token>', {
       templateName: this.props.currentTemplate.name,
       templateName: this.props.currentTemplate.name,
       storage: StorageType.Secret,
       storage: StorageType.Secret,
+      formValues: values,
       namespace: this.state.selectedNamespace,
       namespace: this.state.selectedNamespace,
       name,
       name,
     }, {
     }, {
@@ -92,6 +99,8 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
       let splits = this.state.selectedImageUrl.split(':');
       let splits = this.state.selectedImageUrl.split(':');
       imageUrl = splits[0];
       imageUrl = splits[0];
       tag = splits[1];
       tag = splits[1];
+    } else if (!tag) {
+      tag = 'latest';
     }
     }
 
 
     _.set(values, "image.repository", imageUrl)
     _.set(values, "image.repository", imageUrl)

+ 1 - 1
dashboard/src/shared/Context.tsx

@@ -49,7 +49,7 @@ class ContextProvider extends Component {
     },
     },
     user: null as any,
     user: null as any,
     setUser: (userId: number, email: string) => {
     setUser: (userId: number, email: string) => {
-      this.setState({ user: {userId, email} });
+      this.setState({ user: { userId, email } });
     },
     },
     devOpsMode: true,
     devOpsMode: true,
     setDevOpsMode: (devOpsMode: boolean) => {
     setDevOpsMode: (devOpsMode: boolean) => {

+ 58 - 39
dashboard/src/shared/api.tsx

@@ -95,6 +95,10 @@ const getIngress = baseApi<{
   return `/api/projects/${pathParams.id}/k8s/${pathParams.namespace}/ingress/${pathParams.name}`;
   return `/api/projects/${pathParams.id}/k8s/${pathParams.namespace}/ingress/${pathParams.name}`;
 });
 });
 
 
+const getInvites = baseApi<{}, { id: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/invites`;
+});
+
 const getRevisions = baseApi<{
 const getRevisions = baseApi<{
   namespace: string,
   namespace: string,
   cluster_id: number,
   cluster_id: number,
@@ -173,6 +177,10 @@ const deleteProject = baseApi<{}, { id: number }>('DELETE', pathParams => {
   return `/api/projects/${pathParams.id}`;
   return `/api/projects/${pathParams.id}`;
 });
 });
 
 
+const deleteInvite = baseApi<{}, { id: number, invId: number }>('DELETE', pathParams => {
+  return `/api/projects/${pathParams.id}/invites/${pathParams.invId}`;
+});
+
 const deployTemplate = baseApi<{
 const deployTemplate = baseApi<{
   templateName: string,
   templateName: string,
   imageURL?: string,
   imageURL?: string,
@@ -191,15 +199,15 @@ const deployTemplate = baseApi<{
 });
 });
 
 
 const uninstallTemplate = baseApi<{
 const uninstallTemplate = baseApi<{
-  namespace: string,
-  cluster_id: number,
-  storage: StorageType,
 }, {
 }, {
   id: number,
   id: number,
   name: string, 
   name: string, 
   cluster_id: number,
   cluster_id: number,
+  namespace: string,
+  storage: StorageType,
 }>('POST', pathParams => {
 }>('POST', pathParams => {
-  return `/api/projects/${pathParams.id}/deploy/${pathParams.name}?cluster_id=${pathParams.cluster_id}`;
+  let { id, name, cluster_id, storage, namespace } = pathParams;
+  return `/api/projects/${id}/deploy/${name}?cluster_id=${cluster_id}&namespace=${namespace}&storage=${storage}`;
 });
 });
 
 
 const getClusterIntegrations = baseApi('GET', '/api/integrations/cluster');
 const getClusterIntegrations = baseApi('GET', '/api/integrations/cluster');
@@ -332,54 +340,65 @@ const createGKE = baseApi<{
   return `/api/projects/${pathParams.project_id}/provision/gke`;
   return `/api/projects/${pathParams.project_id}/provision/gke`;
 });
 });
 
 
+const createInvite = baseApi<{
+  email: string
+}, {
+  id: number
+}>('POST', pathParams => {
+  return `/api/projects/${pathParams.id}/invites`;
+})
+
 // Bundle export to allow default api import (api.<method> is more readable)
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
 export default {
-  uninstallTemplate,
+  checkAuth,
+  createAWSIntegration,
+  createECR,
+  createGCPIntegration,
   createGCR,
   createGCR,
   createGKE,
   createGKE,
-  createGCPIntegration,
+  createInvite,
+  createProject,
   deleteCluster,
   deleteCluster,
+  deleteInvite,
+  deleteProject,
+  deployTemplate,
   destroyCluster,
   destroyCluster,
-  getInfra,
-  linkGithubProject,
-  getGitRepos,
-  checkAuth,
-  registerUser,
-  logInUser,
-  logOutUser,
-  getRepos,
-  getUser,
-  updateUser,
-  getClusters,
-  getCharts,
+  getBranchContents,
+  getBranches,
   getChart,
   getChart,
+  getCharts,
   getChartComponents,
   getChartComponents,
   getChartControllers,
   getChartControllers,
-  getNamespaces,
-  getMatchingPods,
-  getIngress,
-  getRevisions,
-  rollbackChart,
-  upgradeChartValues,
-  getTemplates,
-  getTemplateInfo,
-  getBranches,
-  getBranchContents,
-  getProjects,
-  getReleaseToken,
-  createProject,
-  deleteProject,
-  deployTemplate,
   getClusterIntegrations,
   getClusterIntegrations,
-  getRegistryIntegrations,
-  getRepoIntegrations,
+  getClusters,
+  getGitRepos,
+  getImageRepos,
+  getImageTags,
+  getInfra,
+  getIngress,
+  getInvites,
+  getMatchingPods,
+  getNamespaces,
   getProjectClusters,
   getProjectClusters,
   getProjectRegistries,
   getProjectRegistries,
   getProjectRepos,
   getProjectRepos,
-  createAWSIntegration,
+  getProjects,
+  getRegistryIntegrations,
+  getReleaseToken,
+  getRepoIntegrations,
+  getRepos,
+  getRevisions,
+  getTemplateInfo,
+  getTemplates,
+  getUser,
+  linkGithubProject,
+  logInUser,
+  logOutUser,
   provisionECR,
   provisionECR,
   provisionEKS,
   provisionEKS,
-  createECR,
-  getImageRepos,
-  getImageTags,
+  registerUser,
+  rollbackChart,
+  uninstallTemplate,
+  updateUser,
+  upgradeChartValues,
 }
 }

+ 2 - 0
dashboard/src/shared/baseApi.tsx

@@ -56,6 +56,8 @@ export const baseApi = <T extends {}, S = {}>(requestType: string, endpoint: ((p
         }
         }
       })
       })
       .then(res => {
       .then(res => {
+        console.log('from axio')
+        console.log(res.data);
         callback && callback(null, res);
         callback && callback(null, res);
       })
       })
       .catch(err => {
       .catch(err => {

+ 66 - 0
dashboard/src/shared/common.tsx

@@ -1,6 +1,16 @@
 import aws from '../assets/aws.png';
 import aws from '../assets/aws.png';
 import digitalOcean from '../assets/do.png';
 import digitalOcean from '../assets/do.png';
 import gcp from '../assets/gcp.png';
 import gcp from '../assets/gcp.png';
+import { InfraType } from '../shared/types';
+
+export const infraNames: any = {
+  'ecr': 'Elastic Container Registry (ECR)',
+  'eks': 'Elastic Kubernetes Service (EKS)',
+  'gcr': 'Google Container Registry (GCR)',
+  'gke': 'Google Kubernetes Engine (GKE)',
+  'docr': 'Digital Ocean Container Registry',
+  'doks': 'Digital Ocean Kubernetes Service'
+};
 
 
 export const integrationList: any = {
 export const integrationList: any = {
   'kubernetes': {
   'kubernetes': {
@@ -56,8 +66,64 @@ export const integrationList: any = {
   }
   }
 };
 };
 
 
+export const isAlphanumeric = (x: string | null) => {
+  let re = /^[a-z0-9-]+$/;
+  if (!x || x.length == 0 || x.search(re) === -1) {
+    return false;
+  }
+  return true;
+}
+
 export const getIgnoreCase = (object: any, key: string) => {
 export const getIgnoreCase = (object: any, key: string) => {
   return object[Object.keys(object)
   return object[Object.keys(object)
     .find(k => k.toLowerCase() === key.toLowerCase())
     .find(k => k.toLowerCase() === key.toLowerCase())
   ];
   ];
+}
+
+export const includesCompletedInfraSet = (infras: InfraType[]): boolean => {
+  if (infras.length === 0) {
+    return true;
+  }
+
+  let infraSets = [
+    ['ecr', 'eks'],
+    ['gcr', 'gke'],
+    ['docr', 'doks']
+  ];
+
+  let completed = [] as string[];
+  infras.forEach((infra: InfraType, i: number) => {
+    if (infra.status === 'created') {
+      completed.push(infra.kind);
+    }
+  });
+
+  completed.forEach((kind: string, i: number) => {
+    infraSets.forEach((infraSet: string[], i: number) => {
+      infraSet.includes(kind) && infraSet.splice(infraSet.indexOf(kind), 1);
+    });
+  });
+
+  let anyCompleted = false;
+  infraSets.forEach((infraSet: string[], i: number) => {
+    if (infraSet.length === 0) {
+      anyCompleted = true;
+    }
+  })
+  return anyCompleted;
+}
+
+export const filterOldInfras = (infras: InfraType[]): InfraType[] => {
+  let newestInstances = {} as any;
+  infras.forEach((infra: InfraType, i: number) => {
+    if (!newestInstances[infra.kind]) {
+      newestInstances[infra.kind] = infra;
+    } else {
+      let existingId = newestInstances[infra.kind].id;
+      if (infra.id > existingId) {
+        newestInstances[infra.kind] = infra;
+      }
+    }
+  });
+  return Object.values(newestInstances);
 }
 }

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

@@ -145,7 +145,15 @@ export interface ImageType {
 
 
 export interface InfraType {
 export interface InfraType {
   id: number,
   id: number,
-  project_d: number,
+  project_id: number,
   kind: string,
   kind: string,
   status: string,
   status: string,
+}
+
+export interface InviteType {
+  token: string,
+  expired: boolean,
+  email: string,
+  accepted: boolean,
+  id: number,
 }
 }

+ 38 - 0
docker/nginx_remote.conf

@@ -0,0 +1,38 @@
+events {}
+http {
+    upstream api {
+        server localhost:8081;
+    }
+
+    upstream webpack {
+        server localhost:8082;
+    }
+
+    server {
+        listen 8080;
+        server_name localhost;
+
+        location /api/ {
+            proxy_pass http://api;
+            proxy_http_version 1.1;
+            proxy_set_header Upgrade $http_upgrade;
+            proxy_set_header Connection 'upgrade';
+            proxy_set_header Host $host;
+            proxy_cache_bypass $http_upgrade;
+            proxy_set_header   X-Forwarded-Host $server_name;
+        }
+
+        location / {
+            proxy_pass http://webpack;
+            proxy_pass_header Content-Security-Policy;
+            proxy_http_version 1.1;
+            proxy_set_header Upgrade $http_upgrade;
+            proxy_set_header Connection 'upgrade';
+            proxy_set_header Host $host;
+            proxy_cache_bypass $http_upgrade;
+            proxy_set_header   X-Forwarded-Host $server_name;
+        }
+    }
+
+    client_max_body_size 10M;
+}

+ 3 - 0
go.mod

@@ -13,7 +13,9 @@ require (
 	github.com/containerd/containerd v1.4.1 // indirect
 	github.com/containerd/containerd v1.4.1 // indirect
 	github.com/coreos/rkt v1.30.0
 	github.com/coreos/rkt v1.30.0
 	github.com/creack/pty v1.1.11 // indirect
 	github.com/creack/pty v1.1.11 // indirect
+	github.com/digitalocean/godo v1.56.0
 	github.com/docker/cli v0.0.0-20200130152716-5d0cf8839492
 	github.com/docker/cli v0.0.0-20200130152716-5d0cf8839492
+	github.com/docker/distribution v2.7.1+incompatible
 	github.com/docker/docker v1.4.2-0.20200203170920-46ec8731fbce
 	github.com/docker/docker v1.4.2-0.20200203170920-46ec8731fbce
 	github.com/docker/docker-credential-helpers v0.6.3
 	github.com/docker/docker-credential-helpers v0.6.3
 	github.com/docker/go-connections v0.4.0
 	github.com/docker/go-connections v0.4.0
@@ -27,6 +29,7 @@ require (
 	github.com/go-redis/redis/v7 v7.4.0
 	github.com/go-redis/redis/v7 v7.4.0
 	github.com/go-redis/redis/v8 v8.3.1
 	github.com/go-redis/redis/v8 v8.3.1
 	github.com/go-test/deep v1.0.7
 	github.com/go-test/deep v1.0.7
+	github.com/google/go-cmp v0.5.2
 	github.com/google/go-github v17.0.0+incompatible
 	github.com/google/go-github v17.0.0+incompatible
 	github.com/google/go-querystring v1.0.0 // indirect
 	github.com/google/go-querystring v1.0.0 // indirect
 	github.com/googleapis/gnostic v0.2.2 // indirect
 	github.com/googleapis/gnostic v0.2.2 // indirect

+ 4 - 0
go.sum

@@ -230,6 +230,8 @@ github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZm
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
 github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
 github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
+github.com/digitalocean/godo v1.56.0 h1:wXqWJyywrDO3YO2T4Kh8TwbCPOa+OI2vC8qh0/Ngmjk=
+github.com/digitalocean/godo v1.56.0/go.mod h1:p7dOjjtSBqCTUksqtA5Fd3uaKs9kyTq2xcz76ulEJRU=
 github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E=
 github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E=
 github.com/docker/cli v0.0.0-20200130152716-5d0cf8839492 h1:FwssHbCDJD025h+BchanCwE1Q8fyMgqDr2mOQAWOLGw=
 github.com/docker/cli v0.0.0-20200130152716-5d0cf8839492 h1:FwssHbCDJD025h+BchanCwE1Q8fyMgqDr2mOQAWOLGw=
 github.com/docker/cli v0.0.0-20200130152716-5d0cf8839492/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
 github.com/docker/cli v0.0.0-20200130152716-5d0cf8839492/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
@@ -1479,6 +1481,8 @@ k8s.io/cli-runtime v0.18.8/go.mod h1:7EzWiDbS9PFd0hamHHVoCY4GrokSTPSL32MA4rzIu0M
 k8s.io/client-go v0.16.8/go.mod h1:WmPuN0yJTKHXoklExKxzo3jSXmr3EnN+65uaTb5VuNs=
 k8s.io/client-go v0.16.8/go.mod h1:WmPuN0yJTKHXoklExKxzo3jSXmr3EnN+65uaTb5VuNs=
 k8s.io/client-go v0.18.8 h1:SdbLpIxk5j5YbFr1b7fq8S7mDgDjYmUxSbszyoesoDM=
 k8s.io/client-go v0.18.8 h1:SdbLpIxk5j5YbFr1b7fq8S7mDgDjYmUxSbszyoesoDM=
 k8s.io/client-go v0.18.8/go.mod h1:HqFqMllQ5NnQJNwjro9k5zMyfhZlOwpuTLVrxjkYSxU=
 k8s.io/client-go v0.18.8/go.mod h1:HqFqMllQ5NnQJNwjro9k5zMyfhZlOwpuTLVrxjkYSxU=
+k8s.io/client-go v1.5.1 h1:XaX/lo2/u3/pmFau8HN+sB5C/b4dc4Dmm2eXjBH4p1E=
+k8s.io/client-go v11.0.0+incompatible h1:LBbX2+lOwY9flffWlJM7f1Ct8V2SRNiMRDFeiwnJo9o=
 k8s.io/code-generator v0.16.8/go.mod h1:wFdrXdVi/UC+xIfLi+4l9elsTT/uEF61IfcN2wOLULQ=
 k8s.io/code-generator v0.16.8/go.mod h1:wFdrXdVi/UC+xIfLi+4l9elsTT/uEF61IfcN2wOLULQ=
 k8s.io/code-generator v0.18.8/go.mod h1:TgNEVx9hCyPGpdtCWA34olQYLkh3ok9ar7XfSsr8b6c=
 k8s.io/code-generator v0.18.8/go.mod h1:TgNEVx9hCyPGpdtCWA34olQYLkh3ok9ar7XfSsr8b6c=
 k8s.io/component-base v0.16.8/go.mod h1:Q8UWOWShpP3MZZny4n/15gOncfaaVtc9SbCdkM5MhUE=
 k8s.io/component-base v0.16.8/go.mod h1:Q8UWOWShpP3MZZny4n/15gOncfaaVtc9SbCdkM5MhUE=

+ 4 - 2
internal/config/config.go

@@ -29,12 +29,14 @@ type ServerConf struct {
 	IsLocal        bool          `env:"IS_LOCAL,default=false"`
 	IsLocal        bool          `env:"IS_LOCAL,default=false"`
 	IsTesting      bool          `env:"IS_TESTING,default=false"`
 	IsTesting      bool          `env:"IS_TESTING,default=false"`
 
 
-	DefaultHelmRepoURL string `env:"HELM_REPO_URL,default=https://s2011r2593.github.io/test-porter-chart-repo/"`
+	DefaultHelmRepoURL string `env:"HELM_REPO_URL,default=https://porter-dev.github.io/chart-repo/"`
 
 
 	GithubClientID     string `env:"GITHUB_CLIENT_ID"`
 	GithubClientID     string `env:"GITHUB_CLIENT_ID"`
 	GithubClientSecret string `env:"GITHUB_CLIENT_SECRET"`
 	GithubClientSecret string `env:"GITHUB_CLIENT_SECRET"`
 
 
-	ProvisionerImageTag string `env:"PROV_IMAGE_TAG,default-latest"`
+	DOClientID          string `env:"DO_CLIENT_ID"`
+	DOClientSecret      string `env:"DO_CLIENT_SECRET"`
+	ProvisionerImageTag string `env:"PROV_IMAGE_TAG,default=latest"`
 }
 }
 
 
 // DBConf is the database configuration: if generated from environment variables,
 // DBConf is the database configuration: if generated from environment variables,

+ 68 - 0
internal/forms/infra.go

@@ -9,6 +9,22 @@ import (
 
 
 const randCharset string = "abcdefghijklmnopqrstuvwxyz1234567890"
 const randCharset string = "abcdefghijklmnopqrstuvwxyz1234567890"
 
 
+// CreateTestInfra represents the accepted values for creating test
+// infra via the provisioning container
+type CreateTestInfra struct {
+	ProjectID uint `json:"project_id" form:"required"`
+}
+
+// ToInfra converts the form to a gorm aws infra model
+func (ce *CreateTestInfra) ToInfra() (*models.Infra, error) {
+	return &models.Infra{
+		Kind:      models.InfraTest,
+		ProjectID: ce.ProjectID,
+		Suffix:    stringWithCharset(6, randCharset),
+		Status:    models.StatusCreating,
+	}, nil
+}
+
 // CreateECRInfra represents the accepted values for creating an
 // CreateECRInfra represents the accepted values for creating an
 // ECR infra via the provisioning container
 // ECR infra via the provisioning container
 type CreateECRInfra struct {
 type CreateECRInfra struct {
@@ -84,6 +100,46 @@ func (ce *CreateGKEInfra) ToInfra() (*models.Infra, error) {
 	}, nil
 	}, nil
 }
 }
 
 
+// CreateDOCRInfra represents the accepted values for creating an
+// DOCR infra via the provisioning container
+type CreateDOCRInfra struct {
+	DOCRName             string `json:"docr_name" form:"required"`
+	DOCRSubscriptionTier string `json:"docr_subscription_tier" form:"required"`
+	ProjectID            uint   `json:"project_id" form:"required"`
+	DOIntegrationID      uint   `json:"do_integration_id" form:"required"`
+}
+
+// ToInfra converts the form to a gorm infra model
+func (de *CreateDOCRInfra) ToInfra() (*models.Infra, error) {
+	return &models.Infra{
+		Kind:            models.InfraDOCR,
+		ProjectID:       de.ProjectID,
+		Suffix:          stringWithCharset(6, randCharset),
+		Status:          models.StatusCreating,
+		DOIntegrationID: de.DOIntegrationID,
+	}, nil
+}
+
+// CreateDOKSInfra represents the accepted values for creating a
+// DOKS infra via the provisioning container
+type CreateDOKSInfra struct {
+	DORegion        string `json:"do_region" form:"required"`
+	DOKSName        string `json:"doks_name" form:"required"`
+	ProjectID       uint   `json:"project_id" form:"required"`
+	DOIntegrationID uint   `json:"do_integration_id" form:"required"`
+}
+
+// ToInfra converts the form to a gorm infra model
+func (de *CreateDOKSInfra) ToInfra() (*models.Infra, error) {
+	return &models.Infra{
+		Kind:            models.InfraDOKS,
+		ProjectID:       de.ProjectID,
+		Suffix:          stringWithCharset(6, randCharset),
+		Status:          models.StatusCreating,
+		DOIntegrationID: de.DOIntegrationID,
+	}, nil
+}
+
 // DestroyECRInfra represents the accepted values for destroying an
 // DestroyECRInfra represents the accepted values for destroying an
 // ECR infra via the provisioning container
 // ECR infra via the provisioning container
 type DestroyECRInfra struct {
 type DestroyECRInfra struct {
@@ -102,6 +158,18 @@ type DestroyGKEInfra struct {
 	GKEName string `json:"gke_name" form:"required"`
 	GKEName string `json:"gke_name" form:"required"`
 }
 }
 
 
+// DestroyDOCRInfra represents the accepted values for destroying an
+// DOCR infra via the provisioning container
+type DestroyDOCRInfra struct {
+	DOCRName string `json:"docr_name" form:"required"`
+}
+
+// DestroyDOKSInfra represents the accepted values for destroying an
+// DOKS infra via the provisioning container
+type DestroyDOKSInfra struct {
+	DOKSName string `json:"doks_name" form:"required"`
+}
+
 // helpers for random string
 // helpers for random string
 var seededRand *rand.Rand = rand.New(
 var seededRand *rand.Rand = rand.New(
 	rand.NewSource(time.Now().UnixNano()))
 	rand.NewSource(time.Now().UnixNano()))

+ 28 - 0
internal/forms/invite.go

@@ -0,0 +1,28 @@
+package forms
+
+import (
+	"time"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/oauth"
+)
+
+// CreateInvite represents the accepted values for creating an
+// invite to a project
+type CreateInvite struct {
+	Email     string `json:"email" form:"required"`
+	ProjectID uint   `form:"required"`
+}
+
+// ToInvite converts the project to a gorm project model
+func (ci *CreateInvite) ToInvite() (*models.Invite, error) {
+	// generate a token and an expiry time
+	expiry := time.Now().Add(24 * time.Hour)
+
+	return &models.Invite{
+		Email:     ci.Email,
+		Expiry:    &expiry,
+		ProjectID: ci.ProjectID,
+		Token:     oauth.CreateRandomState(),
+	}, nil
+}

+ 2 - 0
internal/forms/registry.go

@@ -14,6 +14,7 @@ type CreateRegistry struct {
 	URL              string `json:"url"`
 	URL              string `json:"url"`
 	GCPIntegrationID uint   `json:"gcp_integration_id"`
 	GCPIntegrationID uint   `json:"gcp_integration_id"`
 	AWSIntegrationID uint   `json:"aws_integration_id"`
 	AWSIntegrationID uint   `json:"aws_integration_id"`
+	DOIntegrationID  uint   `json:"do_integration_id"`
 }
 }
 
 
 // ToRegistry converts the form to a gorm registry model
 // ToRegistry converts the form to a gorm registry model
@@ -24,6 +25,7 @@ func (cr *CreateRegistry) ToRegistry(repo repository.Repository) (*models.Regist
 		URL:              cr.URL,
 		URL:              cr.URL,
 		GCPIntegrationID: cr.GCPIntegrationID,
 		GCPIntegrationID: cr.GCPIntegrationID,
 		AWSIntegrationID: cr.AWSIntegrationID,
 		AWSIntegrationID: cr.AWSIntegrationID,
+		DOIntegrationID:  cr.DOIntegrationID,
 	}
 	}
 
 
 	if registry.URL == "" && registry.AWSIntegrationID != 0 {
 	if registry.URL == "" && registry.AWSIntegrationID != 0 {

+ 5 - 1
internal/forms/release.go

@@ -1,6 +1,7 @@
 package forms
 package forms
 
 
 import (
 import (
+	"fmt"
 	"net/url"
 	"net/url"
 	"strconv"
 	"strconv"
 
 
@@ -19,6 +20,7 @@ func (rf *ReleaseForm) PopulateHelmOptionsFromQueryParams(
 	vals url.Values,
 	vals url.Values,
 	repo repository.ClusterRepository,
 	repo repository.ClusterRepository,
 ) error {
 ) error {
+	fmt.Println(vals)
 	if clusterID, ok := vals["cluster_id"]; ok && len(clusterID) == 1 {
 	if clusterID, ok := vals["cluster_id"]; ok && len(clusterID) == 1 {
 		id, err := strconv.ParseUint(clusterID[0], 10, 64)
 		id, err := strconv.ParseUint(clusterID[0], 10, 64)
 
 
@@ -31,15 +33,17 @@ func (rf *ReleaseForm) PopulateHelmOptionsFromQueryParams(
 		if err != nil {
 		if err != nil {
 			return err
 			return err
 		}
 		}
-
+		fmt.Println("setting cluster")
 		rf.Cluster = cluster
 		rf.Cluster = cluster
 	}
 	}
 
 
 	if namespace, ok := vals["namespace"]; ok && len(namespace) == 1 {
 	if namespace, ok := vals["namespace"]; ok && len(namespace) == 1 {
+		fmt.Println("setting namespace")
 		rf.Namespace = namespace[0]
 		rf.Namespace = namespace[0]
 	}
 	}
 
 
 	if storage, ok := vals["storage"]; ok && len(storage) == 1 {
 	if storage, ok := vals["storage"]; ok && len(storage) == 1 {
+		fmt.Println("setting storage")
 		rf.Storage = storage[0]
 		rf.Storage = storage[0]
 	}
 	}
 
 

+ 65 - 14
internal/helm/agent.go

@@ -4,6 +4,10 @@ import (
 	"fmt"
 	"fmt"
 
 
 	"github.com/pkg/errors"
 	"github.com/pkg/errors"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"golang.org/x/oauth2"
 	"helm.sh/helm/v3/pkg/action"
 	"helm.sh/helm/v3/pkg/action"
 	"helm.sh/helm/v3/pkg/chart"
 	"helm.sh/helm/v3/pkg/chart"
 	"helm.sh/helm/v3/pkg/release"
 	"helm.sh/helm/v3/pkg/release"
@@ -13,6 +17,7 @@ import (
 // Agent is a Helm agent for performing helm operations
 // Agent is a Helm agent for performing helm operations
 type Agent struct {
 type Agent struct {
 	ActionConfig *action.Configuration
 	ActionConfig *action.Configuration
+	K8sAgent     *kubernetes.Agent
 }
 }
 
 
 // ListReleases lists releases based on a ListFilter
 // ListReleases lists releases based on a ListFilter
@@ -49,10 +54,19 @@ func (a *Agent) GetReleaseHistory(
 	return cmd.Run(name)
 	return cmd.Run(name)
 }
 }
 
 
+type UpgradeReleaseConfig struct {
+	Name       string
+	Values     map[string]interface{}
+	Cluster    *models.Cluster
+	Repo       repository.Repository
+	Registries []*models.Registry
+}
+
 // UpgradeRelease upgrades a specific release with new values.yaml
 // UpgradeRelease upgrades a specific release with new values.yaml
 func (a *Agent) UpgradeRelease(
 func (a *Agent) UpgradeRelease(
-	name string,
+	conf *UpgradeReleaseConfig,
 	values string,
 	values string,
+	doAuth *oauth2.Config,
 ) (*release.Release, error) {
 ) (*release.Release, error) {
 	valuesYaml, err := chartutil.ReadValues([]byte(values))
 	valuesYaml, err := chartutil.ReadValues([]byte(values))
 
 
@@ -60,16 +74,18 @@ func (a *Agent) UpgradeRelease(
 		return nil, fmt.Errorf("Values could not be parsed: %v", err)
 		return nil, fmt.Errorf("Values could not be parsed: %v", err)
 	}
 	}
 
 
-	return a.UpgradeReleaseByValues(name, valuesYaml)
+	conf.Values = valuesYaml
+
+	return a.UpgradeReleaseByValues(conf, doAuth)
 }
 }
 
 
 // UpgradeReleaseByValues upgrades a release by unmarshaled yaml values
 // UpgradeReleaseByValues upgrades a release by unmarshaled yaml values
 func (a *Agent) UpgradeReleaseByValues(
 func (a *Agent) UpgradeReleaseByValues(
-	name string,
-	values map[string]interface{},
+	conf *UpgradeReleaseConfig,
+	doAuth *oauth2.Config,
 ) (*release.Release, error) {
 ) (*release.Release, error) {
 	// grab the latest release
 	// grab the latest release
-	rel, err := a.GetRelease(name, 0)
+	rel, err := a.GetRelease(conf.Name, 0)
 
 
 	if err != nil {
 	if err != nil {
 		return nil, fmt.Errorf("Could not get release to be upgraded: %v", err)
 		return nil, fmt.Errorf("Could not get release to be upgraded: %v", err)
@@ -78,7 +94,23 @@ func (a *Agent) UpgradeReleaseByValues(
 	ch := rel.Chart
 	ch := rel.Chart
 
 
 	cmd := action.NewUpgrade(a.ActionConfig)
 	cmd := action.NewUpgrade(a.ActionConfig)
-	res, err := cmd.Run(name, ch, values)
+
+	if conf.Cluster != nil && a.K8sAgent != nil && conf.Registries != nil && len(conf.Registries) > 0 {
+		cmd.PostRenderer, err = NewDockerSecretsPostRenderer(
+			conf.Cluster,
+			conf.Repo,
+			a.K8sAgent,
+			rel.Namespace,
+			conf.Registries,
+			doAuth,
+		)
+
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	res, err := cmd.Run(conf.Name, ch, conf.Values)
 
 
 	if err != nil {
 	if err != nil {
 		return nil, fmt.Errorf("Upgrade failed: %v", err)
 		return nil, fmt.Errorf("Upgrade failed: %v", err)
@@ -89,16 +121,20 @@ func (a *Agent) UpgradeReleaseByValues(
 
 
 // InstallChartConfig is the config required to install a chart
 // InstallChartConfig is the config required to install a chart
 type InstallChartConfig struct {
 type InstallChartConfig struct {
-	Chart     *chart.Chart
-	Name      string
-	Namespace string
-	Values    map[string]interface{}
+	Chart      *chart.Chart
+	Name       string
+	Namespace  string
+	Values     map[string]interface{}
+	Cluster    *models.Cluster
+	Repo       repository.Repository
+	Registries []*models.Registry
 }
 }
 
 
 // InstallChartFromValuesBytes reads the raw values and calls Agent.InstallChart
 // InstallChartFromValuesBytes reads the raw values and calls Agent.InstallChart
 func (a *Agent) InstallChartFromValuesBytes(
 func (a *Agent) InstallChartFromValuesBytes(
 	conf *InstallChartConfig,
 	conf *InstallChartConfig,
 	values []byte,
 	values []byte,
+	doAuth *oauth2.Config,
 ) (*release.Release, error) {
 ) (*release.Release, error) {
 	valuesYaml, err := chartutil.ReadValues(values)
 	valuesYaml, err := chartutil.ReadValues(values)
 
 
@@ -108,12 +144,13 @@ func (a *Agent) InstallChartFromValuesBytes(
 
 
 	conf.Values = valuesYaml
 	conf.Values = valuesYaml
 
 
-	return a.InstallChart(conf)
+	return a.InstallChart(conf, doAuth)
 }
 }
 
 
 // InstallChart installs a new chart
 // InstallChart installs a new chart
 func (a *Agent) InstallChart(
 func (a *Agent) InstallChart(
 	conf *InstallChartConfig,
 	conf *InstallChartConfig,
+	doAuth *oauth2.Config,
 ) (*release.Release, error) {
 ) (*release.Release, error) {
 	cmd := action.NewInstall(a.ActionConfig)
 	cmd := action.NewInstall(a.ActionConfig)
 
 
@@ -128,9 +165,23 @@ func (a *Agent) InstallChart(
 		return nil, err
 		return nil, err
 	}
 	}
 
 
-	// if chartRequested.Metadata.Deprecated {
-	// 	return nil, fmt.Errorf("This chart is deprecated")
-	// }
+	var err error
+
+	// only add the postrenderer if required fields exist
+	if conf.Cluster != nil && a.K8sAgent != nil && conf.Registries != nil && len(conf.Registries) > 0 {
+		cmd.PostRenderer, err = NewDockerSecretsPostRenderer(
+			conf.Cluster,
+			conf.Repo,
+			a.K8sAgent,
+			conf.Namespace,
+			conf.Registries,
+			doAuth,
+		)
+
+		if err != nil {
+			return nil, err
+		}
+	}
 
 
 	if req := conf.Chart.Metadata.Dependencies; req != nil {
 	if req := conf.Chart.Metadata.Dependencies; req != nil {
 		if err := action.CheckDependencies(conf.Chart, req); err != nil {
 		if err := action.CheckDependencies(conf.Chart, req); err != nil {

+ 37 - 26
internal/helm/config.go

@@ -8,6 +8,7 @@ import (
 	"github.com/porter-dev/porter/internal/logger"
 	"github.com/porter-dev/porter/internal/logger"
 	"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"
+	"golang.org/x/oauth2"
 	"helm.sh/helm/v3/pkg/action"
 	"helm.sh/helm/v3/pkg/action"
 	"helm.sh/helm/v3/pkg/chartutil"
 	"helm.sh/helm/v3/pkg/chartutil"
 	"helm.sh/helm/v3/pkg/kube"
 	"helm.sh/helm/v3/pkg/kube"
@@ -19,10 +20,11 @@ import (
 // Form represents the options for connecting to a cluster and
 // Form represents the options for connecting to a cluster and
 // creating a Helm agent
 // creating a Helm agent
 type Form struct {
 type Form struct {
-	Cluster   *models.Cluster `form:"required"`
-	Repo      *repository.Repository
-	Storage   string `json:"storage" form:"oneof=secret configmap memory" default:"secret"`
-	Namespace string `json:"namespace"`
+	Cluster           *models.Cluster `form:"required"`
+	Repo              *repository.Repository
+	DigitalOceanOAuth *oauth2.Config
+	Storage           string `json:"storage" form:"oneof=secret configmap memory" default:"secret"`
+	Namespace         string `json:"namespace"`
 }
 }
 
 
 // GetAgentOutOfClusterConfig creates a new Agent from outside the cluster using
 // GetAgentOutOfClusterConfig creates a new Agent from outside the cluster using
@@ -30,8 +32,9 @@ type Form struct {
 func GetAgentOutOfClusterConfig(form *Form, l *logger.Logger) (*Agent, error) {
 func GetAgentOutOfClusterConfig(form *Form, l *logger.Logger) (*Agent, error) {
 	// create a kubernetes agent
 	// create a kubernetes agent
 	conf := &kubernetes.OutOfClusterConfig{
 	conf := &kubernetes.OutOfClusterConfig{
-		Cluster: form.Cluster,
-		Repo:    form.Repo,
+		Cluster:           form.Cluster,
+		Repo:              form.Repo,
+		DigitalOceanOAuth: form.DigitalOceanOAuth,
 	}
 	}
 
 
 	k8sAgent, err := kubernetes.GetAgentOutOfClusterConfig(conf)
 	k8sAgent, err := kubernetes.GetAgentOutOfClusterConfig(conf)
@@ -52,12 +55,15 @@ func GetAgentFromK8sAgent(stg string, ns string, l *logger.Logger, k8sAgent *kub
 	}
 	}
 
 
 	// use k8s agent to create Helm agent
 	// use k8s agent to create Helm agent
-	return &Agent{&action.Configuration{
-		RESTClientGetter: k8sAgent.RESTClientGetter,
-		KubeClient:       kube.New(k8sAgent.RESTClientGetter),
-		Releases:         StorageMap[stg](l, clientset.CoreV1(), ns),
-		Log:              l.Printf,
-	}}, nil
+	return &Agent{
+		ActionConfig: &action.Configuration{
+			RESTClientGetter: k8sAgent.RESTClientGetter,
+			KubeClient:       kube.New(k8sAgent.RESTClientGetter),
+			Releases:         StorageMap[stg](l, clientset.CoreV1(), ns),
+			Log:              l.Printf,
+		},
+		K8sAgent: k8sAgent,
+	}, nil
 }
 }
 
 
 // GetAgentInClusterConfig creates a new Agent from inside the cluster using
 // GetAgentInClusterConfig creates a new Agent from inside the cluster using
@@ -77,12 +83,15 @@ func GetAgentInClusterConfig(form *Form, l *logger.Logger) (*Agent, error) {
 	}
 	}
 
 
 	// use k8s agent to create Helm agent
 	// use k8s agent to create Helm agent
-	return &Agent{&action.Configuration{
-		RESTClientGetter: k8sAgent.RESTClientGetter,
-		KubeClient:       kube.New(k8sAgent.RESTClientGetter),
-		Releases:         StorageMap[form.Storage](l, clientset.CoreV1(), form.Namespace),
-		Log:              l.Printf,
-	}}, nil
+	return &Agent{
+		ActionConfig: &action.Configuration{
+			RESTClientGetter: k8sAgent.RESTClientGetter,
+			KubeClient:       kube.New(k8sAgent.RESTClientGetter),
+			Releases:         StorageMap[form.Storage](l, clientset.CoreV1(), form.Namespace),
+			Log:              l.Printf,
+		},
+		K8sAgent: k8sAgent,
+	}, nil
 }
 }
 
 
 // GetAgentTesting creates a new Agent using an optional existing storage class
 // GetAgentTesting creates a new Agent using an optional existing storage class
@@ -93,14 +102,16 @@ func GetAgentTesting(form *Form, storage *storage.Storage, l *logger.Logger) *Ag
 		testStorage = StorageMap["memory"](nil, nil, "")
 		testStorage = StorageMap["memory"](nil, nil, "")
 	}
 	}
 
 
-	return &Agent{&action.Configuration{
-		Releases: testStorage,
-		KubeClient: &kubefake.FailingKubeClient{
-			PrintingKubeClient: kubefake.PrintingKubeClient{
-				Out: ioutil.Discard,
+	return &Agent{
+		ActionConfig: &action.Configuration{
+			Releases: testStorage,
+			KubeClient: &kubefake.FailingKubeClient{
+				PrintingKubeClient: kubefake.PrintingKubeClient{
+					Out: ioutil.Discard,
+				},
 			},
 			},
+			Capabilities: chartutil.DefaultCapabilities,
+			Log:          l.Printf,
 		},
 		},
-		Capabilities: chartutil.DefaultCapabilities,
-		Log:          l.Printf,
-	}}
+	}
 }
 }

+ 439 - 0
internal/helm/postrenderer.go

@@ -0,0 +1,439 @@
+package helm
+
+import (
+	"bytes"
+	"io"
+	"net/url"
+	"regexp"
+	"strings"
+
+	"github.com/aws/aws-sdk-go/aws/arn"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/repository"
+	"golang.org/x/oauth2"
+	"gopkg.in/yaml.v2"
+	"helm.sh/helm/v3/pkg/postrender"
+
+	"github.com/docker/distribution/reference"
+)
+
+// DockerSecretsPostRenderer is a Helm post-renderer that adds image pull secrets to
+// pod specs that would otherwise be unable to pull an image.
+//
+// The post-renderer currently looks for two types of registries: GCR and ECR (TODO: DOCR
+// and Dockerhub). It also detects if the image pull secret is necessary: if GCR image pulls
+// occur in a GKE cluster in the same project, or if ECR image pulls exist in an EKS cluster
+// in the same organization + region, an image pull is not necessary.
+type DockerSecretsPostRenderer struct {
+	Cluster   *models.Cluster
+	Repo      repository.Repository
+	Agent     *kubernetes.Agent
+	Namespace string
+	DOAuth    *oauth2.Config
+
+	registries map[string]*models.Registry
+
+	podSpecs  []resource
+	resources []resource
+}
+
+// while manifests are map[string]interface{} at the top level,
+// nested keys will be of type map[interface{}]interface{}
+type resource map[interface{}]interface{}
+
+func NewDockerSecretsPostRenderer(
+	cluster *models.Cluster,
+	repo repository.Repository,
+	agent *kubernetes.Agent,
+	namespace string,
+	regs []*models.Registry,
+	doAuth *oauth2.Config,
+) (postrender.PostRenderer, error) {
+	// Registries is a map of registry URLs to registry ids
+	registries := make(map[string]*models.Registry)
+
+	for _, reg := range regs {
+		regURL := reg.URL
+
+		if !strings.Contains(regURL, "http") {
+			regURL = "https://" + regURL
+		}
+
+		parsedRegURL, err := url.Parse(regURL)
+
+		if err != nil {
+			continue
+		}
+
+		addReg := parsedRegURL.Host
+
+		if parsedRegURL.Path != "" {
+			addReg += "/" + strings.Trim(parsedRegURL.Path, "/")
+		}
+
+		registries[addReg] = reg
+	}
+
+	return &DockerSecretsPostRenderer{
+		Cluster:    cluster,
+		Repo:       repo,
+		Agent:      agent,
+		Namespace:  namespace,
+		DOAuth:     doAuth,
+		registries: registries,
+		podSpecs:   make([]resource, 0),
+		resources:  make([]resource, 0),
+	}, nil
+}
+
+func (d *DockerSecretsPostRenderer) Run(
+	renderedManifests *bytes.Buffer,
+) (modifiedManifests *bytes.Buffer, err error) {
+	bufCopy := bytes.NewBuffer(renderedManifests.Bytes())
+
+	linkedRegs, err := d.getRegistriesToLink(bufCopy)
+
+	// if we encountered an error here, we'll render the manifests anyway
+	// without modification
+	if err != nil {
+		return renderedManifests, nil
+	}
+
+	// create the necessary secrets
+	secrets, err := d.Agent.CreateImagePullSecrets(
+		d.Repo,
+		d.Namespace,
+		linkedRegs,
+		d.DOAuth,
+	)
+
+	if err != nil {
+		return renderedManifests, nil
+	}
+
+	d.updatePodSpecs(secrets)
+
+	modifiedManifests = bytes.NewBuffer([]byte{})
+	encoder := yaml.NewEncoder(modifiedManifests)
+	defer encoder.Close()
+
+	for _, resource := range d.resources {
+		err = encoder.Encode(resource)
+
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	return modifiedManifests, nil
+}
+
+func (d *DockerSecretsPostRenderer) getRegistriesToLink(renderedManifests *bytes.Buffer) (map[string]*models.Registry, error) {
+	// create a map of registry names to registries: these are the registries
+	// that a secret will be generated for, if it does not exist
+	linkedRegs := make(map[string]*models.Registry)
+
+	err := d.decodeRenderedManifests(renderedManifests)
+
+	if err != nil {
+		return linkedRegs, err
+	}
+
+	// read the pod specs into the post-renderer object
+	d.getPodSpecs(d.resources)
+
+	for _, podSpec := range d.podSpecs {
+		// get all images
+		images := d.getImageList(podSpec)
+
+		// read the image url
+		for _, image := range images {
+			named, err := reference.ParseNormalizedNamed(image)
+
+			if err != nil {
+				continue
+			}
+
+			domain := reference.Domain(named)
+			path := reference.Path(named)
+
+			regName := domain
+
+			if pathArr := strings.Split(path, "/"); len(pathArr) > 1 {
+				regName += "/" + strings.Join(pathArr[:len(pathArr)-1], "/")
+			}
+
+			// check if the integration is native to the cluster/registry combination
+			isNative := d.isRegistryNative(regName)
+
+			if isNative {
+				continue
+			}
+
+			reg, exists := d.registries[regName]
+
+			if !exists {
+				continue
+			}
+
+			// if the registry exists, add it to the map
+			linkedRegs[regName] = reg
+		}
+	}
+
+	return linkedRegs, nil
+}
+
+func (d *DockerSecretsPostRenderer) decodeRenderedManifests(
+	renderedManifests *bytes.Buffer,
+) error {
+	// use the yaml decoder to parse the multi-document yaml.
+	decoder := yaml.NewDecoder(renderedManifests)
+
+	for {
+		res := make(resource)
+		err := decoder.Decode(&res)
+		if err == io.EOF {
+			break
+		}
+
+		if err != nil {
+			return err
+		}
+
+		d.resources = append(d.resources, res)
+	}
+
+	return nil
+}
+
+func (d *DockerSecretsPostRenderer) getPodSpecs(resources []resource) {
+	for _, res := range resources {
+		kindVal, hasKind := res["kind"]
+		if !hasKind {
+			continue
+		}
+
+		kind, ok := kindVal.(string)
+		if !ok {
+			continue
+		}
+
+		// manifests of list type will have an items field, items should
+		// be recursively parsed
+		if itemsVal, isList := res["items"]; isList {
+			if items, ok := itemsVal.([]interface{}); ok {
+				// convert items to resource
+				resArr := make([]resource, 0)
+				for _, item := range items {
+					if arrVal, ok := item.(resource); ok {
+						resArr = append(resArr, arrVal)
+					}
+				}
+
+				d.getPodSpecs(resArr)
+			}
+
+			continue
+		}
+
+		// otherwise, get the pod spec based on the type of resource
+		podSpec := getPodSpecFromResource(kind, res)
+
+		if podSpec == nil {
+			continue
+		}
+
+		d.podSpecs = append(d.podSpecs, podSpec)
+	}
+
+	return
+}
+
+func (d *DockerSecretsPostRenderer) updatePodSpecs(secrets map[string]string) {
+	for _, podSpec := range d.podSpecs {
+		containersVal, hasContainers := podSpec["containers"]
+
+		if !hasContainers {
+			continue
+		}
+
+		containers, ok := containersVal.([]interface{})
+
+		if !ok {
+			continue
+		}
+
+		imagePullSecrets := make([]map[string]interface{}, 0)
+
+		if existingPullSecrets, ok := podSpec["imagePullSecrets"]; ok {
+			if existing, ok := existingPullSecrets.([]map[string]interface{}); ok {
+				imagePullSecrets = existing
+			}
+		}
+
+		for _, container := range containers {
+			_container, ok := container.(resource)
+
+			if !ok {
+				continue
+			}
+
+			image, ok := _container["image"].(string)
+
+			if !ok {
+				continue
+			}
+
+			named, err := reference.ParseNormalizedNamed(image)
+
+			if err != nil {
+				continue
+			}
+
+			domain := reference.Domain(named)
+			path := reference.Path(named)
+
+			regName := domain
+
+			if pathArr := strings.Split(path, "/"); len(pathArr) > 1 {
+				regName += "/" + strings.Join(pathArr[:len(pathArr)-1], "/")
+			}
+
+			if secretName, ok := secrets[regName]; ok && secretName != "" {
+				imagePullSecrets = append(imagePullSecrets, map[string]interface{}{
+					"name": secretName,
+				})
+			}
+		}
+
+		if len(imagePullSecrets) > 0 {
+			podSpec["imagePullSecrets"] = imagePullSecrets
+		}
+	}
+}
+
+func (d *DockerSecretsPostRenderer) getImageList(podSpec resource) []string {
+	images := make([]string, 0)
+
+	containersVal, hasContainers := podSpec["containers"]
+
+	if !hasContainers {
+		return images
+	}
+
+	containers, ok := containersVal.([]interface{})
+
+	if !ok {
+		return images
+	}
+
+	for _, container := range containers {
+		_container, ok := container.(resource)
+
+		if !ok {
+			continue
+		}
+
+		image, ok := _container["image"].(string)
+
+		if !ok {
+			continue
+		}
+
+		images = append(images, image)
+	}
+
+	return images
+}
+
+var ecrPattern = regexp.MustCompile(`(^[a-zA-Z0-9][a-zA-Z0-9-_]*)\.dkr\.ecr(\-fips)?\.([a-zA-Z0-9][a-zA-Z0-9-_]*)\.amazonaws\.com(\.cn)?`)
+
+func (d *DockerSecretsPostRenderer) isRegistryNative(regName string) bool {
+	isNative := false
+
+	if strings.Contains(regName, "gcr") && d.Cluster.AuthMechanism == models.GCP {
+		// get the project id of the cluster
+		gcpInt, err := d.Repo.GCPIntegration.ReadGCPIntegration(d.Cluster.GCPIntegrationID)
+
+		if err != nil {
+			return false
+		}
+
+		gkeProjectID, err := integrations.GCPProjectIDFromJSON(gcpInt.GCPKeyData)
+
+		if err != nil {
+			return false
+		}
+
+		// parse the project id of the gcr url
+		if regNameArr := strings.Split(regName, "/"); len(regNameArr) >= 2 {
+			gcrProjectID := regNameArr[1]
+
+			isNative = gcrProjectID == gkeProjectID
+		}
+	} else if strings.Contains(regName, "ecr") && d.Cluster.AuthMechanism == models.AWS {
+		matches := ecrPattern.FindStringSubmatch(regName)
+
+		if len(matches) < 3 {
+			return false
+		}
+
+		eksAccountID := matches[1]
+		eksRegion := matches[3]
+
+		awsInt, err := d.Repo.AWSIntegration.ReadAWSIntegration(d.Cluster.AWSIntegrationID)
+
+		if err != nil {
+			return false
+		}
+
+		err = awsInt.PopulateAWSArn()
+
+		if err != nil {
+			return false
+		}
+
+		parsedARN, err := arn.Parse(awsInt.AWSArn)
+
+		if err != nil {
+			return false
+		}
+
+		isNative = parsedARN.AccountID == eksAccountID && parsedARN.Region == eksRegion
+	}
+
+	return isNative
+}
+
+func getPodSpecFromResource(kind string, res resource) resource {
+	switch kind {
+	case "Pod":
+		return getNestedResource(res, "spec")
+	case "DaemonSet", "Deployment", "Job", "ReplicaSet", "ReplicationController", "StatefulSet":
+		return getNestedResource(res, "spec", "template", "spec")
+	case "PodTemplate":
+		return getNestedResource(res, "template", "spec")
+	case "CronJob":
+		return getNestedResource(res, "spec", "jobTemplate", "spec", "template", "spec")
+	}
+
+	return nil
+}
+
+func getNestedResource(res resource, keys ...string) resource {
+	curr := res
+	var ok bool
+
+	for _, key := range keys {
+		curr, ok = curr[key].(resource)
+
+		if !ok {
+			return nil
+		}
+	}
+
+	return curr
+}

+ 235 - 13
internal/kubernetes/agent.go

@@ -2,6 +2,7 @@ package kubernetes
 
 
 import (
 import (
 	"bufio"
 	"bufio"
+	"bytes"
 	"context"
 	"context"
 	"fmt"
 	"fmt"
 	"io"
 	"io"
@@ -11,10 +12,17 @@ import (
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/ecr"
 	"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/aws/eks"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do"
+	"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"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/gcp"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/gcp/gke"
 	"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/models/integrations"
 	"github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/oauth"
+	"github.com/porter-dev/porter/internal/registry"
+	"github.com/porter-dev/porter/internal/repository"
+	"golang.org/x/oauth2"
 
 
 	"github.com/gorilla/websocket"
 	"github.com/gorilla/websocket"
 	"github.com/porter-dev/porter/internal/helm/grapher"
 	"github.com/porter-dev/porter/internal/helm/grapher"
@@ -22,6 +30,7 @@ import (
 	batchv1 "k8s.io/api/batch/v1"
 	batchv1 "k8s.io/api/batch/v1"
 	v1 "k8s.io/api/core/v1"
 	v1 "k8s.io/api/core/v1"
 	v1beta1 "k8s.io/api/extensions/v1beta1"
 	v1beta1 "k8s.io/api/extensions/v1beta1"
+	"k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/cli-runtime/pkg/genericclioptions"
 	"k8s.io/cli-runtime/pkg/genericclioptions"
 	"k8s.io/client-go/informers"
 	"k8s.io/client-go/informers"
@@ -114,8 +123,9 @@ func (a *Agent) GetPodsByLabel(selector string) (*v1.PodList, error) {
 
 
 // GetPodLogs streams real-time logs from a given pod.
 // GetPodLogs streams real-time logs from a given pod.
 func (a *Agent) GetPodLogs(namespace string, name string, conn *websocket.Conn) error {
 func (a *Agent) GetPodLogs(namespace string, name string, conn *websocket.Conn) error {
+	tails := int64(400)
+
 	// follow logs
 	// follow logs
-	tails := int64(30)
 	podLogOpts := v1.PodLogOptions{
 	podLogOpts := v1.PodLogOptions{
 		Follow:    true,
 		Follow:    true,
 		TailLines: &tails,
 		TailLines: &tails,
@@ -150,6 +160,7 @@ func (a *Agent) GetPodLogs(namespace string, name string, conn *websocket.Conn)
 				return
 				return
 			default:
 			default:
 			}
 			}
+
 			bytes, err := r.ReadBytes('\n')
 			bytes, err := r.ReadBytes('\n')
 			if writeErr := conn.WriteMessage(websocket.TextMessage, bytes); writeErr != nil {
 			if writeErr := conn.WriteMessage(websocket.TextMessage, bytes); writeErr != nil {
 				errorchan <- writeErr
 				errorchan <- writeErr
@@ -241,13 +252,14 @@ func (a *Agent) ProvisionECR(
 	projectID uint,
 	projectID uint,
 	awsConf *integrations.AWSIntegration,
 	awsConf *integrations.AWSIntegration,
 	ecrName string,
 	ecrName string,
+	repo repository.Repository,
 	infra *models.Infra,
 	infra *models.Infra,
 	operation provisioner.ProvisionerOperation,
 	operation provisioner.ProvisionerOperation,
 	pgConf *config.DBConf,
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
 	redisConf *config.RedisConf,
 	provImageTag string,
 	provImageTag string,
 ) (*batchv1.Job, error) {
 ) (*batchv1.Job, error) {
-	id := infra.GetID()
+	id := infra.GetUniqueName()
 	prov := &provisioner.Conf{
 	prov := &provisioner.Conf{
 		ID:                  id,
 		ID:                  id,
 		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
 		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
@@ -256,6 +268,7 @@ func (a *Agent) ProvisionECR(
 		Redis:               redisConf,
 		Redis:               redisConf,
 		Postgres:            pgConf,
 		Postgres:            pgConf,
 		ProvisionerImageTag: provImageTag,
 		ProvisionerImageTag: provImageTag,
+		LastApplied:         infra.LastApplied,
 		AWS: &aws.Conf{
 		AWS: &aws.Conf{
 			AWSRegion:          awsConf.AWSRegion,
 			AWSRegion:          awsConf.AWSRegion,
 			AWSAccessKeyID:     string(awsConf.AWSAccessKeyID),
 			AWSAccessKeyID:     string(awsConf.AWSAccessKeyID),
@@ -266,7 +279,7 @@ func (a *Agent) ProvisionECR(
 		},
 		},
 	}
 	}
 
 
-	return a.provision(prov)
+	return a.provision(prov, infra, repo)
 }
 }
 
 
 // ProvisionEKS spawns a new provisioning pod that creates an EKS instance
 // ProvisionEKS spawns a new provisioning pod that creates an EKS instance
@@ -274,13 +287,14 @@ func (a *Agent) ProvisionEKS(
 	projectID uint,
 	projectID uint,
 	awsConf *integrations.AWSIntegration,
 	awsConf *integrations.AWSIntegration,
 	eksName string,
 	eksName string,
+	repo repository.Repository,
 	infra *models.Infra,
 	infra *models.Infra,
 	operation provisioner.ProvisionerOperation,
 	operation provisioner.ProvisionerOperation,
 	pgConf *config.DBConf,
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
 	redisConf *config.RedisConf,
 	provImageTag string,
 	provImageTag string,
 ) (*batchv1.Job, error) {
 ) (*batchv1.Job, error) {
-	id := infra.GetID()
+	id := infra.GetUniqueName()
 	prov := &provisioner.Conf{
 	prov := &provisioner.Conf{
 		ID:                  id,
 		ID:                  id,
 		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
 		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
@@ -289,6 +303,7 @@ func (a *Agent) ProvisionEKS(
 		Redis:               redisConf,
 		Redis:               redisConf,
 		Postgres:            pgConf,
 		Postgres:            pgConf,
 		ProvisionerImageTag: provImageTag,
 		ProvisionerImageTag: provImageTag,
+		LastApplied:         infra.LastApplied,
 		AWS: &aws.Conf{
 		AWS: &aws.Conf{
 			AWSRegion:          awsConf.AWSRegion,
 			AWSRegion:          awsConf.AWSRegion,
 			AWSAccessKeyID:     string(awsConf.AWSAccessKeyID),
 			AWSAccessKeyID:     string(awsConf.AWSAccessKeyID),
@@ -299,20 +314,21 @@ func (a *Agent) ProvisionEKS(
 		},
 		},
 	}
 	}
 
 
-	return a.provision(prov)
+	return a.provision(prov, infra, repo)
 }
 }
 
 
 // ProvisionGCR spawns a new provisioning pod that creates a GCR instance
 // ProvisionGCR spawns a new provisioning pod that creates a GCR instance
 func (a *Agent) ProvisionGCR(
 func (a *Agent) ProvisionGCR(
 	projectID uint,
 	projectID uint,
 	gcpConf *integrations.GCPIntegration,
 	gcpConf *integrations.GCPIntegration,
+	repo repository.Repository,
 	infra *models.Infra,
 	infra *models.Infra,
 	operation provisioner.ProvisionerOperation,
 	operation provisioner.ProvisionerOperation,
 	pgConf *config.DBConf,
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
 	redisConf *config.RedisConf,
 	provImageTag string,
 	provImageTag string,
 ) (*batchv1.Job, error) {
 ) (*batchv1.Job, error) {
-	id := infra.GetID()
+	id := infra.GetUniqueName()
 	prov := &provisioner.Conf{
 	prov := &provisioner.Conf{
 		ID:                  id,
 		ID:                  id,
 		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
 		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
@@ -321,6 +337,7 @@ func (a *Agent) ProvisionGCR(
 		Redis:               redisConf,
 		Redis:               redisConf,
 		Postgres:            pgConf,
 		Postgres:            pgConf,
 		ProvisionerImageTag: provImageTag,
 		ProvisionerImageTag: provImageTag,
+		LastApplied:         infra.LastApplied,
 		GCP: &gcp.Conf{
 		GCP: &gcp.Conf{
 			GCPRegion:    gcpConf.GCPRegion,
 			GCPRegion:    gcpConf.GCPRegion,
 			GCPProjectID: gcpConf.GCPProjectID,
 			GCPProjectID: gcpConf.GCPProjectID,
@@ -328,7 +345,7 @@ func (a *Agent) ProvisionGCR(
 		},
 		},
 	}
 	}
 
 
-	return a.provision(prov)
+	return a.provision(prov, infra, repo)
 }
 }
 
 
 // ProvisionGKE spawns a new provisioning pod that creates a GKE instance
 // ProvisionGKE spawns a new provisioning pod that creates a GKE instance
@@ -336,13 +353,14 @@ func (a *Agent) ProvisionGKE(
 	projectID uint,
 	projectID uint,
 	gcpConf *integrations.GCPIntegration,
 	gcpConf *integrations.GCPIntegration,
 	gkeName string,
 	gkeName string,
+	repo repository.Repository,
 	infra *models.Infra,
 	infra *models.Infra,
 	operation provisioner.ProvisionerOperation,
 	operation provisioner.ProvisionerOperation,
 	pgConf *config.DBConf,
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
 	redisConf *config.RedisConf,
 	provImageTag string,
 	provImageTag string,
 ) (*batchv1.Job, error) {
 ) (*batchv1.Job, error) {
-	id := infra.GetID()
+	id := infra.GetUniqueName()
 	prov := &provisioner.Conf{
 	prov := &provisioner.Conf{
 		ID:                  id,
 		ID:                  id,
 		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
 		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
@@ -351,6 +369,7 @@ func (a *Agent) ProvisionGKE(
 		Redis:               redisConf,
 		Redis:               redisConf,
 		Postgres:            pgConf,
 		Postgres:            pgConf,
 		ProvisionerImageTag: provImageTag,
 		ProvisionerImageTag: provImageTag,
+		LastApplied:         infra.LastApplied,
 		GCP: &gcp.Conf{
 		GCP: &gcp.Conf{
 			GCPRegion:    gcpConf.GCPRegion,
 			GCPRegion:    gcpConf.GCPRegion,
 			GCPProjectID: gcpConf.GCPProjectID,
 			GCPProjectID: gcpConf.GCPProjectID,
@@ -361,20 +380,124 @@ func (a *Agent) ProvisionGKE(
 		},
 		},
 	}
 	}
 
 
-	return a.provision(prov)
+	return a.provision(prov, infra, repo)
+}
+
+// ProvisionDOCR spawns a new provisioning pod that creates a DOCR instance
+func (a *Agent) ProvisionDOCR(
+	projectID uint,
+	doConf *integrations.OAuthIntegration,
+	doAuth *oauth2.Config,
+	repo repository.Repository,
+	docrName, docrSubscriptionTier string,
+	infra *models.Infra,
+	operation provisioner.ProvisionerOperation,
+	pgConf *config.DBConf,
+	redisConf *config.RedisConf,
+	provImageTag string,
+) (*batchv1.Job, error) {
+	// get the token
+	oauthInt, err := repo.OAuthIntegration.ReadOAuthIntegration(
+		infra.DOIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	tok, _, err := oauth.GetAccessToken(oauthInt, doAuth, repo)
+
+	if err != nil {
+		return nil, err
+	}
+
+	id := infra.GetUniqueName()
+	prov := &provisioner.Conf{
+		ID:                  id,
+		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
+		Kind:                provisioner.DOCR,
+		Operation:           operation,
+		Redis:               redisConf,
+		Postgres:            pgConf,
+		ProvisionerImageTag: provImageTag,
+		LastApplied:         infra.LastApplied,
+		DO: &do.Conf{
+			DOToken: tok,
+		},
+		DOCR: &docr.Conf{
+			DOCRName:             docrName,
+			DOCRSubscriptionTier: docrSubscriptionTier,
+		},
+	}
+
+	return a.provision(prov, infra, repo)
+}
+
+// ProvisionDOKS spawns a new provisioning pod that creates a DOKS instance
+func (a *Agent) ProvisionDOKS(
+	projectID uint,
+	doConf *integrations.OAuthIntegration,
+	doAuth *oauth2.Config,
+	repo repository.Repository,
+	doRegion, doksClusterName string,
+	infra *models.Infra,
+	operation provisioner.ProvisionerOperation,
+	pgConf *config.DBConf,
+	redisConf *config.RedisConf,
+	provImageTag string,
+) (*batchv1.Job, error) {
+	// get the token
+	oauthInt, err := repo.OAuthIntegration.ReadOAuthIntegration(
+		infra.DOIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	tok, _, err := oauth.GetAccessToken(oauthInt, doAuth, repo)
+
+	if err != nil {
+		return nil, err
+	}
+
+	id := infra.GetUniqueName()
+	prov := &provisioner.Conf{
+		ID:                  id,
+		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
+		Kind:                provisioner.DOKS,
+		Operation:           operation,
+		Redis:               redisConf,
+		Postgres:            pgConf,
+		LastApplied:         infra.LastApplied,
+		ProvisionerImageTag: provImageTag,
+		DO: &do.Conf{
+			DOToken: tok,
+		},
+		DOKS: &doks.Conf{
+			DORegion:        doRegion,
+			DOKSClusterName: doksClusterName,
+		},
+	}
+
+	return a.provision(prov, infra, repo)
 }
 }
 
 
 // ProvisionTest spawns a new provisioning pod that tests provisioning
 // ProvisionTest spawns a new provisioning pod that tests provisioning
 func (a *Agent) ProvisionTest(
 func (a *Agent) ProvisionTest(
 	projectID uint,
 	projectID uint,
+	infra *models.Infra,
+	repo repository.Repository,
 	operation provisioner.ProvisionerOperation,
 	operation provisioner.ProvisionerOperation,
 	pgConf *config.DBConf,
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
 	redisConf *config.RedisConf,
 	provImageTag string,
 	provImageTag string,
 ) (*batchv1.Job, error) {
 ) (*batchv1.Job, error) {
+	id := infra.GetUniqueName()
+
 	prov := &provisioner.Conf{
 	prov := &provisioner.Conf{
-		ID:                  fmt.Sprintf("%s-%d", "testing", projectID),
-		Name:                fmt.Sprintf("prov-%s-%d-%s", "testing", projectID, string(operation)),
+		ID:                  id,
+		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
 		Operation:           operation,
 		Operation:           operation,
 		Kind:                provisioner.Test,
 		Kind:                provisioner.Test,
 		Redis:               redisConf,
 		Redis:               redisConf,
@@ -382,11 +505,13 @@ func (a *Agent) ProvisionTest(
 		ProvisionerImageTag: provImageTag,
 		ProvisionerImageTag: provImageTag,
 	}
 	}
 
 
-	return a.provision(prov)
+	return a.provision(prov, infra, repo)
 }
 }
 
 
 func (a *Agent) provision(
 func (a *Agent) provision(
 	prov *provisioner.Conf,
 	prov *provisioner.Conf,
+	infra *models.Infra,
+	repo repository.Repository,
 ) (*batchv1.Job, error) {
 ) (*batchv1.Job, error) {
 	prov.Namespace = "default"
 	prov.Namespace = "default"
 
 
@@ -396,9 +521,106 @@ func (a *Agent) provision(
 		return nil, err
 		return nil, err
 	}
 	}
 
 
-	return a.Clientset.BatchV1().Jobs(prov.Namespace).Create(
+	job, err = a.Clientset.BatchV1().Jobs(prov.Namespace).Create(
 		context.TODO(),
 		context.TODO(),
 		job,
 		job,
 		metav1.CreateOptions{},
 		metav1.CreateOptions{},
 	)
 	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	infra.LastApplied = prov.LastApplied
+	infra, err = repo.Infra.UpdateInfra(infra)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return job, nil
+}
+
+// CreateImagePullSecrets will create the required image pull secrets and
+// return a map from the registry name to the name of the secret.
+func (a *Agent) CreateImagePullSecrets(
+	repo repository.Repository,
+	namespace string,
+	linkedRegs map[string]*models.Registry,
+	doAuth *oauth2.Config,
+) (map[string]string, error) {
+	res := make(map[string]string)
+
+	for key, val := range linkedRegs {
+		_reg := registry.Registry(*val)
+
+		data, err := _reg.GetDockerConfigJSON(repo, doAuth)
+
+		if err != nil {
+			return nil, err
+		}
+
+		secretName := fmt.Sprintf("porter-%s-%d", val.Externalize().Service, val.ID)
+
+		secret, err := a.Clientset.CoreV1().Secrets(namespace).Get(
+			context.TODO(),
+			secretName,
+			metav1.GetOptions{},
+		)
+
+		// if not found, create the secret
+		if err != nil && errors.IsNotFound(err) {
+			_, err = a.Clientset.CoreV1().Secrets(namespace).Create(
+				context.TODO(),
+				&v1.Secret{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: secretName,
+					},
+					Data: map[string][]byte{
+						string(v1.DockerConfigJsonKey): data,
+					},
+					Type: v1.SecretTypeDockerConfigJson,
+				},
+				metav1.CreateOptions{},
+			)
+
+			if err != nil {
+				return nil, err
+			}
+
+			// add secret name to the map
+			res[key] = secretName
+
+			continue
+		} else if err != nil {
+			return nil, err
+		}
+
+		// otherwise, check that the secret contains the correct data: if
+		// if doesn't, update it
+		if !bytes.Equal(secret.Data[v1.DockerConfigJsonKey], data) {
+			_, err := a.Clientset.CoreV1().Secrets(namespace).Update(
+				context.TODO(),
+				&v1.Secret{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: secretName,
+					},
+					Data: map[string][]byte{
+						string(v1.DockerConfigJsonKey): data,
+					},
+					Type: v1.SecretTypeDockerConfigJson,
+				},
+				metav1.UpdateOptions{},
+			)
+
+			if err != nil {
+				return nil, err
+			}
+		}
+
+		// add secret name to the map
+		res[key] = secretName
+	}
+
+	return res, nil
 }
 }

+ 27 - 1
internal/kubernetes/config.go

@@ -8,7 +8,9 @@ import (
 	"time"
 	"time"
 
 
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/repository"
+	"golang.org/x/oauth2"
 	"k8s.io/apimachinery/pkg/api/meta"
 	"k8s.io/apimachinery/pkg/api/meta"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/cli-runtime/pkg/genericclioptions"
 	"k8s.io/cli-runtime/pkg/genericclioptions"
@@ -88,6 +90,9 @@ func GetAgentTesting(objects ...runtime.Object) *Agent {
 type OutOfClusterConfig struct {
 type OutOfClusterConfig struct {
 	Cluster *models.Cluster
 	Cluster *models.Cluster
 	Repo    *repository.Repository
 	Repo    *repository.Repository
+
+	// Only required if using DigitalOcean OAuth as an auth mechanism
+	DigitalOceanOAuth *oauth2.Config
 }
 }
 
 
 // ToRESTConfig creates a kubernetes REST client factory -- it calls ClientConfig on
 // ToRESTConfig creates a kubernetes REST client factory -- it calls ClientConfig on
@@ -268,7 +273,11 @@ func (conf *OutOfClusterConfig) createRawConfigFromCluster() (*api.Config, error
 			return nil, err
 			return nil, err
 		}
 		}
 
 
-		tok, err := gcpAuth.GetBearerToken(conf.getTokenCache, conf.setTokenCache)
+		tok, err := gcpAuth.GetBearerToken(
+			conf.getTokenCache,
+			conf.setTokenCache,
+			"https://www.googleapis.com/auth/cloud-platform",
+		)
 
 
 		if err != nil {
 		if err != nil {
 			return nil, err
 			return nil, err
@@ -291,6 +300,23 @@ func (conf *OutOfClusterConfig) createRawConfigFromCluster() (*api.Config, error
 			return nil, err
 			return nil, err
 		}
 		}
 
 
+		// add this as a bearer token
+		authInfoMap[authInfoName].Token = tok
+	case models.DO:
+		oauthInt, err := conf.Repo.OAuthIntegration.ReadOAuthIntegration(
+			cluster.DOIntegrationID,
+		)
+
+		if err != nil {
+			return nil, err
+		}
+
+		tok, _, err := oauth.GetAccessToken(oauthInt, conf.DigitalOceanOAuth, *conf.Repo)
+
+		if err != nil {
+			return nil, err
+		}
+
 		// add this as a bearer token
 		// add this as a bearer token
 		authInfoMap[authInfoName].Token = tok
 		authInfoMap[authInfoName].Token = tok
 	default:
 	default:

+ 20 - 0
internal/kubernetes/provisioner/do/do.go

@@ -0,0 +1,20 @@
+package do
+
+import (
+	v1 "k8s.io/api/core/v1"
+)
+
+// Conf is just a DO token
+type Conf struct {
+	DOToken string
+}
+
+// AttachDOEnv adds the relevant DO env for the provisioner
+func (conf *Conf) AttachDOEnv(env []v1.EnvVar) []v1.EnvVar {
+	env = append(env, v1.EnvVar{
+		Name:  "DO_TOKEN",
+		Value: conf.DOToken,
+	})
+
+	return env
+}

+ 25 - 0
internal/kubernetes/provisioner/do/docr/docr.go

@@ -0,0 +1,25 @@
+package docr
+
+import (
+	v1 "k8s.io/api/core/v1"
+)
+
+// Conf is just a DO token
+type Conf struct {
+	DOCRName, DOCRSubscriptionTier string
+}
+
+// AttachDOCREnv adds the relevant DO env for the provisioner
+func (conf *Conf) AttachDOCREnv(env []v1.EnvVar) []v1.EnvVar {
+	env = append(env, v1.EnvVar{
+		Name:  "DOCR_NAME",
+		Value: conf.DOCRName,
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "DOCR_SUBSCRIPTION_TIER",
+		Value: conf.DOCRSubscriptionTier,
+	})
+
+	return env
+}

+ 25 - 0
internal/kubernetes/provisioner/do/doks/doks.go

@@ -0,0 +1,25 @@
+package doks
+
+import (
+	v1 "k8s.io/api/core/v1"
+)
+
+// Conf is just a DO token
+type Conf struct {
+	DORegion, DOKSClusterName string
+}
+
+// AttachDOKSEnv adds the relevant DO env for the provisioner
+func (conf *Conf) AttachDOKSEnv(env []v1.EnvVar) []v1.EnvVar {
+	env = append(env, v1.EnvVar{
+		Name:  "DO_REGION",
+		Value: conf.DORegion,
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "DOKS_CLUSTER_NAME",
+		Value: conf.DOKSClusterName,
+	})
+
+	return env
+}

+ 54 - 1
internal/kubernetes/provisioner/global_stream.go

@@ -111,7 +111,7 @@ func GlobalStreamListener(
 		// parse messages from the global stream
 		// parse messages from the global stream
 		for _, msg := range xstreams[0].Messages {
 		for _, msg := range xstreams[0].Messages {
 			// parse the id to identify the infra
 			// parse the id to identify the infra
-			kind, projID, infraID, err := models.ParseWorkspaceID(fmt.Sprintf("%v", msg.Values["id"]))
+			kind, projID, infraID, err := models.ParseUniqueName(fmt.Sprintf("%v", msg.Values["id"]))
 
 
 			if fmt.Sprintf("%v", msg.Values["status"]) == "created" {
 			if fmt.Sprintf("%v", msg.Values["status"]) == "created" {
 				infra, err := repo.Infra.ReadInfra(infraID)
 				infra, err := repo.Infra.ReadInfra(infraID)
@@ -255,6 +255,59 @@ func GlobalStreamListener(
 
 
 					cluster, err := repo.Cluster.CreateCluster(cluster)
 					cluster, err := repo.Cluster.CreateCluster(cluster)
 
 
+					if err != nil {
+						continue
+					}
+				} else if kind == string(models.InfraDOCR) {
+					reg := &models.Registry{
+						ProjectID:       projID,
+						DOIntegrationID: infra.DOIntegrationID,
+						InfraID:         infra.ID,
+					}
+
+					// parse raw data into DOCR type
+					dataString, ok := msg.Values["data"].(string)
+
+					if ok {
+						json.Unmarshal([]byte(dataString), reg)
+					}
+
+					reg, err = repo.Registry.CreateRegistry(reg)
+
+					if err != nil {
+						continue
+					}
+				} else if kind == string(models.InfraDOKS) {
+					cluster := &models.Cluster{
+						AuthMechanism:   models.DO,
+						ProjectID:       projID,
+						DOIntegrationID: infra.DOIntegrationID,
+						InfraID:         infra.ID,
+					}
+
+					// parse raw data into GKE type
+					dataString, ok := msg.Values["data"].(string)
+
+					if ok {
+						json.Unmarshal([]byte(dataString), cluster)
+					}
+
+					re := regexp.MustCompile(`^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$`)
+
+					// if it matches the base64 regex, decode it
+					caData := string(cluster.CertificateAuthorityData)
+					if re.MatchString(caData) {
+						decoded, err := base64.StdEncoding.DecodeString(caData)
+
+						if err != nil {
+							continue
+						}
+
+						cluster.CertificateAuthorityData = []byte(decoded)
+					}
+
+					cluster, err := repo.Cluster.CreateCluster(cluster)
+
 					if err != nil {
 					if err != nil {
 						continue
 						continue
 					}
 					}

+ 23 - 0
internal/kubernetes/provisioner/input/docr.go

@@ -0,0 +1,23 @@
+package input
+
+import (
+	"encoding/json"
+)
+
+type DOCR struct {
+	DOToken              string `json:"do_token"`
+	DOCRName             string `json:"docr_name"`
+	DOCRSubscriptionTier string `json:"docr_subscription_tier"`
+}
+
+func (docr *DOCR) GetInput() ([]byte, error) {
+	return json.Marshal(docr)
+}
+
+func GetDOCRInput(bytes []byte) (*DOCR, error) {
+	res := &DOCR{}
+
+	err := json.Unmarshal(bytes, res)
+
+	return res, err
+}

+ 23 - 0
internal/kubernetes/provisioner/input/doks.go

@@ -0,0 +1,23 @@
+package input
+
+import (
+	"encoding/json"
+)
+
+type DOKS struct {
+	DORegion    string `json:"do_region"`
+	DOToken     string `json:"do_token"`
+	ClusterName string `json:"cluster_name"`
+}
+
+func (doks *DOKS) GetInput() ([]byte, error) {
+	return json.Marshal(doks)
+}
+
+func GetDOKSInput(bytes []byte) (*DOKS, error) {
+	res := &DOKS{}
+
+	err := json.Unmarshal(bytes, res)
+
+	return res, err
+}

+ 24 - 0
internal/kubernetes/provisioner/input/ecr.go

@@ -0,0 +1,24 @@
+package input
+
+import (
+	"encoding/json"
+)
+
+type ECR struct {
+	AWSRegion    string `json:"aws_region"`
+	AWSAccessKey string `json:"aws_access_key"`
+	AWSSecretKey string `json:"aws_secret_key"`
+	ECRName      string `json:"ecr_name"`
+}
+
+func (ecr *ECR) GetInput() ([]byte, error) {
+	return json.Marshal(ecr)
+}
+
+func GetECRInput(bytes []byte) (*ECR, error) {
+	res := &ECR{}
+
+	err := json.Unmarshal(bytes, res)
+
+	return res, err
+}

+ 24 - 0
internal/kubernetes/provisioner/input/eks.go

@@ -0,0 +1,24 @@
+package input
+
+import (
+	"encoding/json"
+)
+
+type EKS struct {
+	AWSRegion    string `json:"aws_region"`
+	AWSAccessKey string `json:"aws_access_key"`
+	AWSSecretKey string `json:"aws_secret_key"`
+	ClusterName  string `json:"cluster_name"`
+}
+
+func (eks *EKS) GetInput() ([]byte, error) {
+	return json.Marshal(eks)
+}
+
+func GetEKSInput(bytes []byte) (*EKS, error) {
+	res := &EKS{}
+
+	err := json.Unmarshal(bytes, res)
+
+	return res, err
+}

+ 23 - 0
internal/kubernetes/provisioner/input/gcr.go

@@ -0,0 +1,23 @@
+package input
+
+import (
+	"encoding/json"
+)
+
+type GCR struct {
+	GCPCredentials string `json:"gcp_credentials"`
+	GCPRegion      string `json:"gcp_region"`
+	GCPProjectID   string `json:"gcp_project_id"`
+}
+
+func (gcr *GCR) GetInput() ([]byte, error) {
+	return json.Marshal(gcr)
+}
+
+func GetGCRInput(bytes []byte) (*GCR, error) {
+	res := &GCR{}
+
+	err := json.Unmarshal(bytes, res)
+
+	return res, err
+}

+ 24 - 0
internal/kubernetes/provisioner/input/gke.go

@@ -0,0 +1,24 @@
+package input
+
+import (
+	"encoding/json"
+)
+
+type GKE struct {
+	GCPCredentials string `json:"gcp_credentials"`
+	GCPRegion      string `json:"gcp_region"`
+	GCPProjectID   string `json:"gcp_project_id"`
+	ClusterName    string `json:"cluster_name"`
+}
+
+func (gke *GKE) GetInput() ([]byte, error) {
+	return json.Marshal(gke)
+}
+
+func GetGKEInput(bytes []byte) (*GKE, error) {
+	res := &GKE{}
+
+	err := json.Unmarshal(bytes, res)
+
+	return res, err
+}

+ 195 - 10
internal/kubernetes/provisioner/provisioner.go

@@ -10,6 +10,10 @@ import (
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/ecr"
 	"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/aws/eks"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do"
+	"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/input"
 
 
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/gcp"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/gcp"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/gcp/gke"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/gcp/gke"
@@ -27,6 +31,8 @@ const (
 	EKS  InfraOption = "eks"
 	EKS  InfraOption = "eks"
 	GCR  InfraOption = "gcr"
 	GCR  InfraOption = "gcr"
 	GKE  InfraOption = "gke"
 	GKE  InfraOption = "gke"
+	DOCR InfraOption = "docr"
+	DOKS InfraOption = "doks"
 )
 )
 
 
 // Conf is the config required to start a provisioner container
 // Conf is the config required to start a provisioner container
@@ -39,6 +45,7 @@ type Conf struct {
 	Postgres            *config.DBConf
 	Postgres            *config.DBConf
 	Operation           ProvisionerOperation
 	Operation           ProvisionerOperation
 	ProvisionerImageTag string
 	ProvisionerImageTag string
+	LastApplied         []byte
 
 
 	// provider-specific configurations
 	// provider-specific configurations
 
 
@@ -50,6 +57,11 @@ type Conf struct {
 	// GKE
 	// GKE
 	GCP *gcp.Conf
 	GCP *gcp.Conf
 	GKE *gke.Conf
 	GKE *gke.Conf
+
+	// DO
+	DO   *do.Conf
+	DOCR *docr.Conf
+	DOKS *doks.Conf
 }
 }
 
 
 type ProvisionerOperation string
 type ProvisionerOperation string
@@ -74,11 +86,7 @@ func (conf *Conf) GetProvisionerJobTemplate() (*batchv1.Job, error) {
 
 
 	ttl := int32(3600)
 	ttl := int32(3600)
 
 
-	backoffLimit := int32(5)
-
-	if operation == string(Apply) {
-		backoffLimit = int32(1)
-	}
+	backoffLimit := int32(1)
 
 
 	labels := map[string]string{
 	labels := map[string]string{
 		"app": "provisioner",
 		"app": "provisioner",
@@ -86,23 +94,200 @@ func (conf *Conf) GetProvisionerJobTemplate() (*batchv1.Job, error) {
 
 
 	args := make([]string, 0)
 	args := make([]string, 0)
 
 
-	if conf.Kind == Test {
+	switch conf.Kind {
+	case Test:
 		args = []string{operation, "test", "hello"}
 		args = []string{operation, "test", "hello"}
-	} else if conf.Kind == ECR {
+	case ECR:
 		args = []string{operation, "ecr"}
 		args = []string{operation, "ecr"}
+
+		if len(conf.LastApplied) > 0 {
+			inputConf, err := input.GetECRInput(conf.LastApplied)
+
+			if err != nil {
+				return nil, err
+			}
+
+			conf.AWS.AWSAccessKeyID = inputConf.AWSAccessKey
+			conf.AWS.AWSSecretAccessKey = inputConf.AWSSecretKey
+			conf.AWS.AWSRegion = inputConf.AWSRegion
+			conf.ECR.ECRName = inputConf.ECRName
+		} else {
+			inputConf := &input.ECR{
+				AWSRegion:    conf.AWS.AWSRegion,
+				AWSAccessKey: conf.AWS.AWSAccessKeyID,
+				AWSSecretKey: conf.AWS.AWSSecretAccessKey,
+				ECRName:      conf.ECR.ECRName,
+			}
+
+			lastApplied, err := inputConf.GetInput()
+
+			if err != nil {
+				return nil, err
+			}
+
+			conf.LastApplied = lastApplied
+		}
+
 		env = conf.AWS.AttachAWSEnv(env)
 		env = conf.AWS.AttachAWSEnv(env)
 		env = conf.ECR.AttachECREnv(env)
 		env = conf.ECR.AttachECREnv(env)
-	} else if conf.Kind == EKS {
+	case EKS:
 		args = []string{operation, "eks"}
 		args = []string{operation, "eks"}
+
+		if len(conf.LastApplied) > 0 {
+			inputConf, err := input.GetEKSInput(conf.LastApplied)
+
+			if err != nil {
+				return nil, err
+			}
+
+			conf.AWS.AWSAccessKeyID = inputConf.AWSAccessKey
+			conf.AWS.AWSSecretAccessKey = inputConf.AWSSecretKey
+			conf.AWS.AWSRegion = inputConf.AWSRegion
+			conf.EKS.ClusterName = inputConf.ClusterName
+		} else {
+			inputConf := &input.EKS{
+				AWSRegion:    conf.AWS.AWSRegion,
+				AWSAccessKey: conf.AWS.AWSAccessKeyID,
+				AWSSecretKey: conf.AWS.AWSSecretAccessKey,
+				ClusterName:  conf.EKS.ClusterName,
+			}
+
+			lastApplied, err := inputConf.GetInput()
+
+			if err != nil {
+				return nil, err
+			}
+
+			conf.LastApplied = lastApplied
+		}
+
 		env = conf.AWS.AttachAWSEnv(env)
 		env = conf.AWS.AttachAWSEnv(env)
 		env = conf.EKS.AttachEKSEnv(env)
 		env = conf.EKS.AttachEKSEnv(env)
-	} else if conf.Kind == GCR {
+	case GCR:
 		args = []string{operation, "gcr"}
 		args = []string{operation, "gcr"}
+
+		if len(conf.LastApplied) > 0 {
+			inputConf, err := input.GetGCRInput(conf.LastApplied)
+
+			if err != nil {
+				return nil, err
+			}
+
+			conf.GCP.GCPKeyData = inputConf.GCPCredentials
+			conf.GCP.GCPRegion = inputConf.GCPRegion
+			conf.GCP.GCPProjectID = inputConf.GCPProjectID
+		} else {
+			inputConf := &input.GCR{
+				GCPCredentials: conf.GCP.GCPKeyData,
+				GCPRegion:      conf.GCP.GCPRegion,
+				GCPProjectID:   conf.GCP.GCPProjectID,
+			}
+
+			lastApplied, err := inputConf.GetInput()
+
+			if err != nil {
+				return nil, err
+			}
+
+			conf.LastApplied = lastApplied
+		}
+
 		env = conf.GCP.AttachGCPEnv(env)
 		env = conf.GCP.AttachGCPEnv(env)
-	} else if conf.Kind == GKE {
+	case GKE:
 		args = []string{operation, "gke"}
 		args = []string{operation, "gke"}
+
+		if len(conf.LastApplied) > 0 {
+			inputConf, err := input.GetGKEInput(conf.LastApplied)
+
+			if err != nil {
+				return nil, err
+			}
+
+			conf.GCP.GCPKeyData = inputConf.GCPCredentials
+			conf.GCP.GCPRegion = inputConf.GCPRegion
+			conf.GCP.GCPProjectID = inputConf.GCPProjectID
+			conf.GKE.ClusterName = inputConf.ClusterName
+		} else {
+			inputConf := &input.GKE{
+				GCPCredentials: conf.GCP.GCPKeyData,
+				GCPRegion:      conf.GCP.GCPRegion,
+				GCPProjectID:   conf.GCP.GCPProjectID,
+				ClusterName:    conf.GKE.ClusterName,
+			}
+
+			lastApplied, err := inputConf.GetInput()
+
+			if err != nil {
+				return nil, err
+			}
+
+			conf.LastApplied = lastApplied
+		}
+
 		env = conf.GCP.AttachGCPEnv(env)
 		env = conf.GCP.AttachGCPEnv(env)
 		env = conf.GKE.AttachGKEEnv(env)
 		env = conf.GKE.AttachGKEEnv(env)
+	case DOCR:
+		args = []string{operation, "docr"}
+
+		if len(conf.LastApplied) > 0 {
+			inputConf, err := input.GetDOCRInput(conf.LastApplied)
+
+			if err != nil {
+				return nil, err
+			}
+
+			conf.DO.DOToken = inputConf.DOToken
+			conf.DOCR.DOCRSubscriptionTier = inputConf.DOCRSubscriptionTier
+			conf.DOCR.DOCRName = inputConf.DOCRName
+		} else {
+			inputConf := &input.DOCR{
+				DOToken:              conf.DO.DOToken,
+				DOCRSubscriptionTier: conf.DOCR.DOCRSubscriptionTier,
+				DOCRName:             conf.DOCR.DOCRName,
+			}
+
+			lastApplied, err := inputConf.GetInput()
+
+			if err != nil {
+				return nil, err
+			}
+
+			conf.LastApplied = lastApplied
+		}
+
+		env = conf.DO.AttachDOEnv(env)
+		env = conf.DOCR.AttachDOCREnv(env)
+	case DOKS:
+		args = []string{operation, "doks"}
+
+		if len(conf.LastApplied) > 0 {
+			inputConf, err := input.GetDOKSInput(conf.LastApplied)
+
+			if err != nil {
+				return nil, err
+			}
+
+			conf.DO.DOToken = inputConf.DOToken
+			conf.DOKS.DORegion = inputConf.DORegion
+			conf.DOKS.DOKSClusterName = inputConf.ClusterName
+		} else {
+			inputConf := &input.DOKS{
+				DOToken:     conf.DO.DOToken,
+				DORegion:    conf.DOKS.DORegion,
+				ClusterName: conf.DOKS.DOKSClusterName,
+			}
+
+			lastApplied, err := inputConf.GetInput()
+
+			if err != nil {
+				return nil, err
+			}
+
+			conf.LastApplied = lastApplied
+		}
+
+		env = conf.DO.AttachDOEnv(env)
+		env = conf.DOKS.AttachDOKSEnv(env)
 	}
 	}
 
 
 	return &batchv1.Job{
 	return &batchv1.Job{

+ 2 - 0
internal/models/cluster.go

@@ -18,6 +18,7 @@ const (
 	OIDC   ClusterAuth = "oidc"
 	OIDC   ClusterAuth = "oidc"
 	GCP    ClusterAuth = "gcp-sa"
 	GCP    ClusterAuth = "gcp-sa"
 	AWS    ClusterAuth = "aws-sa"
 	AWS    ClusterAuth = "aws-sa"
+	DO     ClusterAuth = "do-oauth"
 	Local  ClusterAuth = "local"
 	Local  ClusterAuth = "local"
 )
 )
 
 
@@ -58,6 +59,7 @@ type Cluster struct {
 	OIDCIntegrationID uint
 	OIDCIntegrationID uint
 	GCPIntegrationID  uint
 	GCPIntegrationID  uint
 	AWSIntegrationID  uint
 	AWSIntegrationID  uint
+	DOIntegrationID   uint
 
 
 	// A token cache that can be used by an auth mechanism, if desired
 	// A token cache that can be used by an auth mechanism, if desired
 	TokenCache integrations.ClusterTokenCache `json:"token_cache"`
 	TokenCache integrations.ClusterTokenCache `json:"token_cache"`

+ 21 - 7
internal/models/infra.go

@@ -25,10 +25,13 @@ type InfraKind string
 
 
 // The supported infra kinds
 // The supported infra kinds
 const (
 const (
-	InfraECR InfraKind = "ecr"
-	InfraEKS InfraKind = "eks"
-	InfraGCR InfraKind = "gcr"
-	InfraGKE InfraKind = "gke"
+	InfraTest InfraKind = "test"
+	InfraECR  InfraKind = "ecr"
+	InfraEKS  InfraKind = "eks"
+	InfraGCR  InfraKind = "gcr"
+	InfraGKE  InfraKind = "gke"
+	InfraDOCR InfraKind = "docr"
+	InfraDOKS InfraKind = "doks"
 )
 )
 
 
 // Infra represents the metadata for an infrastructure type provisioned on
 // Infra represents the metadata for an infrastructure type provisioned on
@@ -53,6 +56,17 @@ type Infra struct {
 
 
 	// The GCP integration that was used to create the infra
 	// The GCP integration that was used to create the infra
 	GCPIntegrationID uint
 	GCPIntegrationID uint
+
+	// The DO integration that was used to create the infra:
+	// this points to an OAuthIntegrationID
+	DOIntegrationID uint
+
+	// ------------------------------------------------------------------
+	// All fields below this line are encrypted before storage
+	// ------------------------------------------------------------------
+
+	// The last-applied input variables to the provisioner
+	LastApplied []byte
 }
 }
 
 
 // InfraExternal is an external Infra to be shared over REST
 // InfraExternal is an external Infra to be shared over REST
@@ -80,12 +94,12 @@ func (i *Infra) Externalize() *InfraExternal {
 }
 }
 
 
 // GetID returns the unique id for this infra
 // GetID returns the unique id for this infra
-func (i *Infra) GetID() string {
+func (i *Infra) GetUniqueName() string {
 	return fmt.Sprintf("%s-%d-%d-%s", i.Kind, i.ProjectID, i.ID, i.Suffix)
 	return fmt.Sprintf("%s-%d-%d-%s", i.Kind, i.ProjectID, i.ID, i.Suffix)
 }
 }
 
 
-// ParseWorkspaceID returns the (kind, projectID, infraID)
-func ParseWorkspaceID(workspaceID string) (string, uint, uint, error) {
+// ParseUniqueName returns the (kind, projectID, infraID, suffix)
+func ParseUniqueName(workspaceID string) (string, uint, uint, error) {
 	strArr := strings.Split(workspaceID, "-")
 	strArr := strings.Split(workspaceID, "-")
 
 
 	if len(strArr) < 3 {
 	if len(strArr) < 3 {

+ 40 - 1
internal/models/integrations/gcp.go

@@ -2,6 +2,7 @@ package integrations
 
 
 import (
 import (
 	"context"
 	"context"
+	"encoding/json"
 
 
 	"golang.org/x/oauth2/google"
 	"golang.org/x/oauth2/google"
 	"gorm.io/gorm"
 	"gorm.io/gorm"
@@ -81,6 +82,7 @@ func (g *GCPIntegration) ToProjectIntegration(
 func (g *GCPIntegration) GetBearerToken(
 func (g *GCPIntegration) GetBearerToken(
 	getTokenCache GetTokenCacheFunc,
 	getTokenCache GetTokenCacheFunc,
 	setTokenCache SetTokenCacheFunc,
 	setTokenCache SetTokenCacheFunc,
+	scopes ...string,
 ) (string, error) {
 ) (string, error) {
 	cache, err := getTokenCache()
 	cache, err := getTokenCache()
 
 
@@ -94,7 +96,7 @@ func (g *GCPIntegration) GetBearerToken(
 	creds, err := google.CredentialsFromJSON(
 	creds, err := google.CredentialsFromJSON(
 		context.Background(),
 		context.Background(),
 		g.GCPKeyData,
 		g.GCPKeyData,
-		"https://www.googleapis.com/auth/cloud-platform",
+		scopes...,
 	)
 	)
 
 
 	if err != nil {
 	if err != nil {
@@ -112,3 +114,40 @@ func (g *GCPIntegration) GetBearerToken(
 
 
 	return tok.AccessToken, nil
 	return tok.AccessToken, nil
 }
 }
+
+// credentialsFile is the unmarshalled representation of a GCP credentials file.
+// Source; golang.org/x/oauth2/google
+type credentialsFile struct {
+	Type string `json:"type"` // serviceAccountKey or userCredentialsKey
+
+	// Service Account fields
+	ClientEmail  string `json:"client_email"`
+	PrivateKeyID string `json:"private_key_id"`
+	PrivateKey   string `json:"private_key"`
+	TokenURL     string `json:"token_uri"`
+	ProjectID    string `json:"project_id"`
+
+	// User Credential fields
+	// (These typically come from gcloud auth.)
+	ClientSecret string `json:"client_secret"`
+	ClientID     string `json:"client_id"`
+	RefreshToken string `json:"refresh_token"`
+
+	// External Account fields
+	Audience                       string `json:"audience"`
+	SubjectTokenType               string `json:"subject_token_type"`
+	TokenURLExternal               string `json:"token_url"`
+	TokenInfoURL                   string `json:"token_info_url"`
+	ServiceAccountImpersonationURL string `json:"service_account_impersonation_url"`
+	// CredentialSource               externalaccount.CredentialSource `json:"credential_source"`
+	QuotaProjectID string `json:"quota_project_id"`
+}
+
+func GCPProjectIDFromJSON(jsonData []byte) (string, error) {
+	var f credentialsFile
+	if err := json.Unmarshal(jsonData, &f); err != nil {
+		return "", err
+	}
+
+	return f.ProjectID, nil
+}

+ 1 - 0
internal/models/integrations/integration.go

@@ -13,6 +13,7 @@ const (
 	Kube     IntegrationService = "kube"
 	Kube     IntegrationService = "kube"
 	GCR      IntegrationService = "gcr"
 	GCR      IntegrationService = "gcr"
 	ECR      IntegrationService = "ecr"
 	ECR      IntegrationService = "ecr"
+	DOCR     IntegrationService = "docr"
 	Github   IntegrationService = "github"
 	Github   IntegrationService = "github"
 	Docker   IntegrationService = "docker"
 	Docker   IntegrationService = "docker"
 )
 )

+ 5 - 2
internal/models/integrations/oauth.go

@@ -1,13 +1,16 @@
 package integrations
 package integrations
 
 
-import "gorm.io/gorm"
+import (
+	"gorm.io/gorm"
+)
 
 
 // OAuthIntegrationClient is the name of an OAuth mechanism client
 // OAuthIntegrationClient is the name of an OAuth mechanism client
 type OAuthIntegrationClient string
 type OAuthIntegrationClient string
 
 
 // The supported oauth mechanism clients
 // The supported oauth mechanism clients
 const (
 const (
-	OAuthGithub OAuthIntegrationClient = "github"
+	OAuthGithub       OAuthIntegrationClient = "github"
+	OAuthDigitalOcean OAuthIntegrationClient = "do"
 )
 )
 
 
 // OAuthIntegration is an auth mechanism that uses oauth
 // OAuthIntegration is an auth mechanism that uses oauth

+ 48 - 0
internal/models/invite.go

@@ -0,0 +1,48 @@
+package models
+
+import (
+	"time"
+
+	"gorm.io/gorm"
+)
+
+// Invite type that extends gorm.Model
+type Invite struct {
+	gorm.Model
+
+	Token  string `gorm:"unique"`
+	Expiry *time.Time
+	Email  string
+
+	ProjectID uint
+	UserID    uint
+}
+
+// InviteExternal represents the Invite type that is sent over REST
+type InviteExternal struct {
+	ID       uint   `json:"id"`
+	Token    string `json:"token"`
+	Expired  bool   `json:"expired"`
+	Email    string `json:"email"`
+	Accepted bool   `json:"accepted"`
+}
+
+// Externalize generates an external Invite to be shared over REST
+func (i *Invite) Externalize() *InviteExternal {
+	return &InviteExternal{
+		ID:       i.Model.ID,
+		Token:    i.Token,
+		Email:    i.Email,
+		Expired:  i.IsExpired(),
+		Accepted: i.IsAccepted(),
+	}
+}
+
+func (i *Invite) IsExpired() bool {
+	timeLeft := i.Expiry.Sub(time.Now())
+	return timeLeft < 0
+}
+
+func (i *Invite) IsAccepted() bool {
+	return i.UserID != 0
+}

+ 3 - 0
internal/models/project.go

@@ -26,6 +26,9 @@ type Project struct {
 	// linked helm repos
 	// linked helm repos
 	HelmRepos []HelmRepo `json:"helm_repos"`
 	HelmRepos []HelmRepo `json:"helm_repos"`
 
 
+	// invitations to the project
+	Invites []Invite `json:"invites"`
+
 	// provisioned aws infra
 	// provisioned aws infra
 	Infras []Infra `json:"infras"`
 	Infras []Infra `json:"infras"`
 
 

+ 3 - 0
internal/models/registry.go

@@ -28,6 +28,7 @@ type Registry struct {
 
 
 	GCPIntegrationID uint
 	GCPIntegrationID uint
 	AWSIntegrationID uint
 	AWSIntegrationID uint
+	DOIntegrationID  uint
 
 
 	// A token cache that can be used by an auth mechanism (integration), if desired
 	// A token cache that can be used by an auth mechanism (integration), if desired
 	TokenCache integrations.RegTokenCache
 	TokenCache integrations.RegTokenCache
@@ -61,6 +62,8 @@ func (r *Registry) Externalize() *RegistryExternal {
 		serv = integrations.ECR
 		serv = integrations.ECR
 	} else if r.GCPIntegrationID != 0 {
 	} else if r.GCPIntegrationID != 0 {
 		serv = integrations.GCR
 		serv = integrations.GCR
+	} else if r.DOIntegrationID != 0 {
+		serv = integrations.DOCR
 	}
 	}
 
 
 	return &RegistryExternal{
 	return &RegistryExternal{

+ 50 - 0
internal/oauth/config.go

@@ -1,9 +1,13 @@
 package oauth
 package oauth
 
 
 import (
 import (
+	"context"
 	"crypto/rand"
 	"crypto/rand"
 	"encoding/base64"
 	"encoding/base64"
+	"time"
 
 
+	"github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/repository"
 	"golang.org/x/oauth2"
 	"golang.org/x/oauth2"
 )
 )
 
 
@@ -27,6 +31,19 @@ func NewGithubClient(cfg *Config) *oauth2.Config {
 	}
 	}
 }
 }
 
 
+func NewDigitalOceanClient(cfg *Config) *oauth2.Config {
+	return &oauth2.Config{
+		ClientID:     cfg.ClientID,
+		ClientSecret: cfg.ClientSecret,
+		Endpoint: oauth2.Endpoint{
+			AuthURL:  "https://cloud.digitalocean.com/v1/oauth/authorize",
+			TokenURL: "https://cloud.digitalocean.com/v1/oauth/token",
+		},
+		RedirectURL: cfg.BaseURL + "/api/oauth/digitalocean/callback",
+		Scopes:      cfg.Scopes,
+	}
+}
+
 func CreateRandomState() string {
 func CreateRandomState() string {
 	b := make([]byte, 16)
 	b := make([]byte, 16)
 	rand.Read(b)
 	rand.Read(b)
@@ -35,3 +52,36 @@ func CreateRandomState() string {
 
 
 	return state
 	return state
 }
 }
+
+// GetAccessToken retrieves an access token for a given client. It updates the
+// access token in the DB if necessary
+func GetAccessToken(
+	o *integrations.OAuthIntegration,
+	conf *oauth2.Config,
+	repo repository.Repository,
+) (string, *time.Time, error) {
+	tokSource := conf.TokenSource(context.TODO(), &oauth2.Token{
+		AccessToken:  string(o.AccessToken),
+		RefreshToken: string(o.RefreshToken),
+		TokenType:    "Bearer",
+	})
+
+	token, err := tokSource.Token()
+
+	if err != nil {
+		return "", nil, err
+	}
+
+	if token.AccessToken != string(o.AccessToken) {
+		o.AccessToken = []byte(token.AccessToken)
+		o.RefreshToken = []byte(token.RefreshToken)
+
+		o, err = repo.OAuthIntegration.UpdateOAuthIntegration(o)
+
+		if err != nil {
+			return "", nil, err
+		}
+	}
+
+	return token.AccessToken, &token.Expiry, nil
+}

+ 275 - 2
internal/registry/registry.go

@@ -1,6 +1,8 @@
 package registry
 package registry
 
 
 import (
 import (
+	"context"
+	"encoding/base64"
 	"encoding/json"
 	"encoding/json"
 	"fmt"
 	"fmt"
 	"net/http"
 	"net/http"
@@ -10,9 +12,15 @@ import (
 
 
 	"github.com/aws/aws-sdk-go/service/ecr"
 	"github.com/aws/aws-sdk-go/service/ecr"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/repository"
+	"golang.org/x/oauth2"
 
 
 	ints "github.com/porter-dev/porter/internal/models/integrations"
 	ints "github.com/porter-dev/porter/internal/models/integrations"
+
+	"github.com/digitalocean/godo"
+	"github.com/docker/cli/cli/config/configfile"
+	"github.com/docker/cli/cli/config/types"
 )
 )
 
 
 // Registry wraps the gorm Registry model
 // Registry wraps the gorm Registry model
@@ -46,7 +54,10 @@ type Image struct {
 }
 }
 
 
 // ListRepositories lists the repositories for a registry
 // ListRepositories lists the repositories for a registry
-func (r *Registry) ListRepositories(repo repository.Repository) ([]*Repository, error) {
+func (r *Registry) ListRepositories(
+	repo repository.Repository,
+	doAuth *oauth2.Config, // only required if using DOCR
+) ([]*Repository, error) {
 	// switch on the auth mechanism to get a token
 	// switch on the auth mechanism to get a token
 	if r.AWSIntegrationID != 0 {
 	if r.AWSIntegrationID != 0 {
 		return r.listECRRepositories(repo)
 		return r.listECRRepositories(repo)
@@ -56,6 +67,10 @@ func (r *Registry) ListRepositories(repo repository.Repository) ([]*Repository,
 		return r.listGCRRepositories(repo)
 		return r.listGCRRepositories(repo)
 	}
 	}
 
 
+	if r.DOIntegrationID != 0 {
+		return r.listDOCRRepositories(repo, doAuth)
+	}
+
 	return nil, fmt.Errorf("error listing repositories")
 	return nil, fmt.Errorf("error listing repositories")
 }
 }
 
 
@@ -78,7 +93,11 @@ func (r *Registry) GetGCRToken(repo repository.Repository) (*ints.TokenCache, er
 	}
 	}
 
 
 	// get oauth2 access token
 	// get oauth2 access token
-	_, err = gcp.GetBearerToken(r.getTokenCache, r.setTokenCacheFunc(repo))
+	_, err = gcp.GetBearerToken(
+		r.getTokenCache,
+		r.setTokenCacheFunc(repo),
+		"https://www.googleapis.com/auth/devstorage.read_write",
+	)
 
 
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
@@ -187,6 +206,52 @@ func (r *Registry) listECRRepositories(repo repository.Repository) ([]*Repositor
 	return res, nil
 	return res, nil
 }
 }
 
 
+func (r *Registry) listDOCRRepositories(
+	repo repository.Repository,
+	doAuth *oauth2.Config,
+) ([]*Repository, error) {
+	oauthInt, err := repo.OAuthIntegration.ReadOAuthIntegration(
+		r.DOIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	tok, _, err := oauth.GetAccessToken(oauthInt, doAuth, repo)
+
+	if err != nil {
+		return nil, err
+	}
+
+	client := godo.NewFromToken(tok)
+
+	urlArr := strings.Split(r.URL, "/")
+
+	if len(urlArr) != 2 {
+		return nil, fmt.Errorf("invalid digital ocean registry url")
+	}
+
+	name := urlArr[1]
+
+	repos, _, err := client.Registry.ListRepositories(context.TODO(), name, &godo.ListOptions{})
+
+	if err != nil {
+		return nil, err
+	}
+
+	res := make([]*Repository, 0)
+
+	for _, repo := range repos {
+		res = append(res, &Repository{
+			Name: repo.Name,
+			URI:  r.URL + "/" + repo.Name,
+		})
+	}
+
+	return res, nil
+}
+
 func (r *Registry) getTokenCache() (tok *ints.TokenCache, err error) {
 func (r *Registry) getTokenCache() (tok *ints.TokenCache, err error) {
 	return &ints.TokenCache{
 	return &ints.TokenCache{
 		Token:  r.TokenCache.Token,
 		Token:  r.TokenCache.Token,
@@ -216,6 +281,7 @@ func (r *Registry) setTokenCacheFunc(
 func (r *Registry) ListImages(
 func (r *Registry) ListImages(
 	repoName string,
 	repoName string,
 	repo repository.Repository,
 	repo repository.Repository,
+	doAuth *oauth2.Config, // only required if using DOCR
 ) ([]*Image, error) {
 ) ([]*Image, error) {
 	// switch on the auth mechanism to get a token
 	// switch on the auth mechanism to get a token
 	if r.AWSIntegrationID != 0 {
 	if r.AWSIntegrationID != 0 {
@@ -226,6 +292,10 @@ func (r *Registry) ListImages(
 		return r.listGCRImages(repoName, repo)
 		return r.listGCRImages(repoName, repo)
 	}
 	}
 
 
+	if r.DOIntegrationID != 0 {
+		return r.listDOCRImages(repoName, repo, doAuth)
+	}
+
 	return nil, fmt.Errorf("error listing images")
 	return nil, fmt.Errorf("error listing images")
 }
 }
 
 
@@ -326,3 +396,206 @@ func (r *Registry) listGCRImages(repoName string, repo repository.Repository) ([
 
 
 	return res, nil
 	return res, nil
 }
 }
+
+func (r *Registry) listDOCRImages(
+	repoName string,
+	repo repository.Repository,
+	doAuth *oauth2.Config,
+) ([]*Image, error) {
+	oauthInt, err := repo.OAuthIntegration.ReadOAuthIntegration(
+		r.DOIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	tok, _, err := oauth.GetAccessToken(oauthInt, doAuth, repo)
+
+	if err != nil {
+		return nil, err
+	}
+
+	client := godo.NewFromToken(tok)
+
+	urlArr := strings.Split(r.URL, "/")
+
+	if len(urlArr) != 2 {
+		return nil, fmt.Errorf("invalid digital ocean registry url")
+	}
+
+	name := urlArr[1]
+
+	tags, _, err := client.Registry.ListRepositoryTags(context.TODO(), name, repoName, &godo.ListOptions{})
+
+	if err != nil {
+		return nil, err
+	}
+
+	res := make([]*Image, 0)
+
+	for _, tag := range tags {
+		res = append(res, &Image{
+			RepositoryName: repoName,
+			Tag:            tag.Tag,
+		})
+	}
+
+	return res, nil
+}
+
+// GetDockerConfigJSON returns a dockerconfigjson file contents with "auths"
+// populated.
+func (r *Registry) GetDockerConfigJSON(
+	repo repository.Repository,
+	doAuth *oauth2.Config, // only required if using DOCR
+) ([]byte, error) {
+	var conf *configfile.ConfigFile
+	var err error
+
+	// switch on the auth mechanism to get a token
+	if r.AWSIntegrationID != 0 {
+		conf, err = r.getECRDockerConfigFile(repo)
+	}
+
+	if r.GCPIntegrationID != 0 {
+		conf, err = r.getGCRDockerConfigFile(repo)
+	}
+
+	if r.DOIntegrationID != 0 {
+		conf, err = r.getDOCRDockerConfigFile(repo, doAuth)
+	}
+
+	if err != nil {
+		return nil, err
+	}
+
+	return json.Marshal(conf)
+}
+
+func (r *Registry) getECRDockerConfigFile(
+	repo repository.Repository,
+) (*configfile.ConfigFile, error) {
+	aws, err := repo.AWSIntegration.ReadAWSIntegration(
+		r.AWSIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	sess, err := aws.GetSession()
+
+	if err != nil {
+		return nil, err
+	}
+
+	ecrSvc := ecr.New(sess)
+
+	output, err := ecrSvc.GetAuthorizationToken(&ecr.GetAuthorizationTokenInput{})
+
+	if err != nil {
+		return nil, err
+	}
+
+	token := *output.AuthorizationData[0].AuthorizationToken
+
+	decodedToken, err := base64.StdEncoding.DecodeString(token)
+
+	if err != nil {
+		return nil, err
+	}
+
+	parts := strings.SplitN(string(decodedToken), ":", 2)
+
+	if len(parts) < 2 {
+		return nil, err
+	}
+
+	key := r.URL
+
+	if !strings.Contains(key, "http") {
+		key = "https://" + key
+	}
+
+	return &configfile.ConfigFile{
+		AuthConfigs: map[string]types.AuthConfig{
+			key: types.AuthConfig{
+				Username: parts[0],
+				Password: parts[1],
+				Auth:     token,
+			},
+		},
+	}, nil
+}
+
+func (r *Registry) getGCRDockerConfigFile(
+	repo repository.Repository,
+) (*configfile.ConfigFile, error) {
+	gcp, err := repo.GCPIntegration.ReadGCPIntegration(
+		r.GCPIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	key := r.URL
+
+	if !strings.Contains(key, "http") {
+		key = "https://" + key
+	}
+
+	parsedURL, _ := url.Parse(key)
+
+	return &configfile.ConfigFile{
+		AuthConfigs: map[string]types.AuthConfig{
+			parsedURL.Host: types.AuthConfig{
+				Username: "_json_key",
+				Password: string(gcp.GCPKeyData),
+				Auth:     generateAuthToken("_json_key", string(gcp.GCPKeyData)),
+			},
+		},
+	}, nil
+}
+
+func (r *Registry) getDOCRDockerConfigFile(
+	repo repository.Repository,
+	doAuth *oauth2.Config,
+) (*configfile.ConfigFile, error) {
+	oauthInt, err := repo.OAuthIntegration.ReadOAuthIntegration(
+		r.DOIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	tok, _, err := oauth.GetAccessToken(oauthInt, doAuth, repo)
+
+	if err != nil {
+		return nil, err
+	}
+
+	key := r.URL
+
+	if !strings.Contains(key, "http") {
+		key = "https://" + key
+	}
+
+	parsedURL, _ := url.Parse(key)
+
+	return &configfile.ConfigFile{
+		AuthConfigs: map[string]types.AuthConfig{
+			parsedURL.Host: types.AuthConfig{
+				Username: tok,
+				Password: tok,
+				Auth:     generateAuthToken(tok, tok),
+			},
+		},
+	}, nil
+}
+
+func generateAuthToken(username, password string) string {
+	return base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
+}

+ 23 - 0
internal/repository/gorm/auth.go

@@ -670,6 +670,29 @@ func (repo *OAuthIntegrationRepository) ListOAuthIntegrationsByProjectID(
 	return oauths, nil
 	return oauths, nil
 }
 }
 
 
+// UpdateOAuthIntegration modifies an existing oauth integration in the database
+func (repo *OAuthIntegrationRepository) UpdateOAuthIntegration(
+	am *ints.OAuthIntegration,
+) (*ints.OAuthIntegration, error) {
+	err := repo.EncryptOAuthIntegrationData(am, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if err := repo.db.Save(am).Error; err != nil {
+		return nil, err
+	}
+
+	err = repo.DecryptOAuthIntegrationData(am, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return am, nil
+}
+
 // EncryptOAuthIntegrationData will encrypt the oauth integration data before
 // EncryptOAuthIntegrationData will encrypt the oauth integration data before
 // writing to the DB
 // writing to the DB
 func (repo *OAuthIntegrationRepository) EncryptOAuthIntegrationData(
 func (repo *OAuthIntegrationRepository) EncryptOAuthIntegrationData(

+ 12 - 0
internal/repository/gorm/cluster.go

@@ -197,10 +197,22 @@ func (repo *ClusterRepository) ListClustersByProjectID(
 func (repo *ClusterRepository) UpdateCluster(
 func (repo *ClusterRepository) UpdateCluster(
 	cluster *models.Cluster,
 	cluster *models.Cluster,
 ) (*models.Cluster, error) {
 ) (*models.Cluster, error) {
+	err := repo.EncryptClusterData(cluster, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
 	if err := repo.db.Save(cluster).Error; err != nil {
 	if err := repo.db.Save(cluster).Error; err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
 
 
+	err = repo.DecryptClusterData(cluster, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
 	return cluster, nil
 	return cluster, nil
 }
 }
 
 

+ 28 - 0
internal/repository/gorm/helpers_test.go

@@ -3,6 +3,7 @@ package gorm_test
 import (
 import (
 	"os"
 	"os"
 	"testing"
 	"testing"
+	"time"
 
 
 	"github.com/porter-dev/porter/internal/adapter"
 	"github.com/porter-dev/porter/internal/adapter"
 	"github.com/porter-dev/porter/internal/config"
 	"github.com/porter-dev/porter/internal/config"
@@ -23,6 +24,7 @@ type tester struct {
 	initClusters []*models.Cluster
 	initClusters []*models.Cluster
 	initHRs      []*models.HelmRepo
 	initHRs      []*models.HelmRepo
 	initInfras   []*models.Infra
 	initInfras   []*models.Infra
+	initInvites  []*models.Invite
 	initCCs      []*models.ClusterCandidate
 	initCCs      []*models.ClusterCandidate
 	initKIs      []*ints.KubeIntegration
 	initKIs      []*ints.KubeIntegration
 	initBasics   []*ints.BasicIntegration
 	initBasics   []*ints.BasicIntegration
@@ -58,6 +60,7 @@ func setupTestEnv(tester *tester, t *testing.T) {
 		&models.ClusterCandidate{},
 		&models.ClusterCandidate{},
 		&models.ClusterResolver{},
 		&models.ClusterResolver{},
 		&models.Infra{},
 		&models.Infra{},
+		&models.Invite{},
 		&ints.KubeIntegration{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},
 		&ints.OIDCIntegration{},
@@ -457,3 +460,28 @@ func initInfra(tester *tester, t *testing.T) {
 
 
 	tester.initInfras = append(tester.initInfras, infra)
 	tester.initInfras = append(tester.initInfras, infra)
 }
 }
+
+func initInvite(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	expiry := time.Now().Add(24 * time.Hour)
+
+	invite := &models.Invite{
+		Token:     "abcd",
+		Expiry:    &expiry,
+		Email:     "testing@test.it",
+		ProjectID: 1,
+	}
+
+	invite, err := tester.repo.Invite.CreateInvite(invite)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initInvites = append(tester.initInvites, invite)
+}

+ 80 - 3
internal/repository/gorm/infra.go

@@ -1,6 +1,8 @@
 package gorm
 package gorm
 
 
 import (
 import (
+	"fmt"
+
 	"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"
@@ -8,17 +10,24 @@ import (
 
 
 // InfraRepository uses gorm.DB for querying the database
 // InfraRepository uses gorm.DB for querying the database
 type InfraRepository struct {
 type InfraRepository struct {
-	db *gorm.DB
+	db  *gorm.DB
+	key *[32]byte
 }
 }
 
 
 // NewInfraRepository returns a InfraRepository which uses
 // NewInfraRepository returns a InfraRepository which uses
 // gorm.DB for querying the database
 // gorm.DB for querying the database
-func NewInfraRepository(db *gorm.DB) repository.InfraRepository {
-	return &InfraRepository{db}
+func NewInfraRepository(db *gorm.DB, key *[32]byte) repository.InfraRepository {
+	return &InfraRepository{db, key}
 }
 }
 
 
 // CreateInfra creates a new aws infra
 // CreateInfra creates a new aws infra
 func (repo *InfraRepository) CreateInfra(infra *models.Infra) (*models.Infra, error) {
 func (repo *InfraRepository) CreateInfra(infra *models.Infra) (*models.Infra, error) {
+	err := repo.EncryptInfraData(infra, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
 	project := &models.Project{}
 	project := &models.Project{}
 
 
 	if err := repo.db.Where("id = ?", infra.ProjectID).First(&project).Error; err != nil {
 	if err := repo.db.Where("id = ?", infra.ProjectID).First(&project).Error; err != nil {
@@ -35,6 +44,12 @@ func (repo *InfraRepository) CreateInfra(infra *models.Infra) (*models.Infra, er
 		return nil, err
 		return nil, err
 	}
 	}
 
 
+	err = repo.DecryptInfraData(infra, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
 	return infra, nil
 	return infra, nil
 }
 }
 
 
@@ -46,6 +61,14 @@ func (repo *InfraRepository) ReadInfra(id uint) (*models.Infra, error) {
 		return nil, err
 		return nil, err
 	}
 	}
 
 
+	fmt.Println("INNFRA LAST APPLIED", string(infra.LastApplied))
+
+	err := repo.DecryptInfraData(infra, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
 	return infra, nil
 	return infra, nil
 }
 }
 
 
@@ -60,6 +83,10 @@ func (repo *InfraRepository) ListInfrasByProjectID(
 		return nil, err
 		return nil, err
 	}
 	}
 
 
+	for _, infra := range infras {
+		repo.DecryptInfraData(infra, repo.key)
+	}
+
 	return infras, nil
 	return infras, nil
 }
 }
 
 
@@ -67,9 +94,59 @@ func (repo *InfraRepository) ListInfrasByProjectID(
 func (repo *InfraRepository) UpdateInfra(
 func (repo *InfraRepository) UpdateInfra(
 	ai *models.Infra,
 	ai *models.Infra,
 ) (*models.Infra, error) {
 ) (*models.Infra, error) {
+	err := repo.EncryptInfraData(ai, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
 	if err := repo.db.Save(ai).Error; err != nil {
 	if err := repo.db.Save(ai).Error; err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
 
 
+	err = repo.DecryptInfraData(ai, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
 	return ai, nil
 	return ai, nil
 }
 }
+
+// EncryptInfraData will encrypt the infra data before
+// writing to the DB
+func (repo *InfraRepository) EncryptInfraData(
+	infra *models.Infra,
+	key *[32]byte,
+) error {
+	if len(infra.LastApplied) > 0 {
+		cipherData, err := repository.Encrypt(infra.LastApplied, key)
+
+		if err != nil {
+			return err
+		}
+
+		infra.LastApplied = cipherData
+	}
+
+	return nil
+}
+
+// DecryptInfraData will decrypt the user's infra data before
+// returning it from the DB
+func (repo *InfraRepository) DecryptInfraData(
+	infra *models.Infra,
+	key *[32]byte,
+) error {
+	if len(infra.LastApplied) > 0 {
+		plaintext, err := repository.Decrypt(infra.LastApplied, key)
+
+		if err != nil {
+			return err
+		}
+
+		infra.LastApplied = plaintext
+	}
+
+	return nil
+}

+ 98 - 0
internal/repository/gorm/invite.go

@@ -0,0 +1,98 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// InviteRepository uses gorm.DB for querying the database
+type InviteRepository struct {
+	db *gorm.DB
+}
+
+// NewInviteRepository returns a InviteRepository which uses
+// gorm.DB for querying the database
+func NewInviteRepository(db *gorm.DB) repository.InviteRepository {
+	return &InviteRepository{db}
+}
+
+// CreateInvite creates a new invite
+func (repo *InviteRepository) CreateInvite(invite *models.Invite) (*models.Invite, error) {
+	project := &models.Project{}
+
+	if err := repo.db.Where("id = ?", invite.ProjectID).First(&project).Error; err != nil {
+		return nil, err
+	}
+
+	assoc := repo.db.Model(&project).Association("Invites")
+
+	if assoc.Error != nil {
+		return nil, assoc.Error
+	}
+
+	if err := assoc.Append(invite); err != nil {
+		return nil, err
+	}
+
+	return invite, nil
+}
+
+// ReadInvite gets an invite specified by a unique id
+func (repo *InviteRepository) ReadInvite(id uint) (*models.Invite, error) {
+	invite := &models.Invite{}
+
+	if err := repo.db.Where("id = ?", id).First(&invite).Error; err != nil {
+		return nil, err
+	}
+
+	return invite, nil
+}
+
+// ReadInviteByToken gets an invite specified by a unique token
+func (repo *InviteRepository) ReadInviteByToken(token string) (*models.Invite, error) {
+	invite := &models.Invite{}
+
+	if err := repo.db.Where("token = ?", token).First(&invite).Error; err != nil {
+		return nil, err
+	}
+
+	return invite, nil
+}
+
+// ListInvitesByProjectID finds all invites
+// for a given project id
+func (repo *InviteRepository) ListInvitesByProjectID(
+	projectID uint,
+) ([]*models.Invite, error) {
+	invites := []*models.Invite{}
+
+	if err := repo.db.Where("project_id = ?", projectID).Find(&invites).Error; err != nil {
+		return nil, err
+	}
+
+	return invites, nil
+}
+
+// UpdateInvite updates an invitation in the DB
+func (repo *InviteRepository) UpdateInvite(
+	invite *models.Invite,
+) (*models.Invite, error) {
+	if err := repo.db.Save(invite).Error; err != nil {
+		return nil, err
+	}
+
+	return invite, nil
+}
+
+// DeleteInvite removes a registry from the db
+func (repo *InviteRepository) DeleteInvite(
+	invite *models.Invite,
+) error {
+	// clear TokenCache association
+	if err := repo.db.Where("id = ?", invite.ID).Delete(&models.Invite{}).Error; err != nil {
+		return err
+	}
+
+	return nil
+}

+ 100 - 0
internal/repository/gorm/invite_test.go

@@ -0,0 +1,100 @@
+package gorm_test
+
+import (
+	"testing"
+	"time"
+
+	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+func TestCreateInvite(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_invite.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	defer cleanup(tester, t)
+
+	expiry := time.Now().Add(24 * time.Hour)
+
+	invite := &models.Invite{
+		Token:     "abcd",
+		Expiry:    &expiry,
+		Email:     "testing@test.it",
+		ProjectID: 1,
+	}
+
+	invite, err := tester.repo.Invite.CreateInvite(invite)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	invite, err = tester.repo.Invite.ReadInvite(invite.Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure id is 1, project id is 1 and token is "abcd"
+	if invite.Model.ID != 1 {
+		t.Errorf("incorrect invite ID: expected %d, got %d\n", 1, invite.Model.ID)
+	}
+
+	if invite.ProjectID != 1 {
+		t.Errorf("incorrect invite project ID: expected %d, got %d\n", 1, invite.ProjectID)
+	}
+
+	if invite.Token != "abcd" {
+		t.Errorf("incorrect token: expected %s, got %s\n", "abcd", invite.Token)
+	}
+
+	if invite.Email != "testing@test.it" {
+		t.Errorf("incorrect email: expected %s, got %s\n", "testing@test.it", invite.Email)
+	}
+}
+
+func TestListInvitesByProjectID(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_list_invites.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initInvite(tester, t)
+	defer cleanup(tester, t)
+
+	invites, err := tester.repo.Invite.ListInvitesByProjectID(
+		tester.initProjects[0].Model.ID,
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if len(invites) != 1 {
+		t.Fatalf("length of invites incorrect: expected %d, got %d\n", 1, len(invites))
+	}
+
+	// make sure data is correct
+	expInvite := models.Invite{
+		Token:     "abcd",
+		Email:     "testing@test.it",
+		Expiry:    &time.Time{},
+		ProjectID: 1,
+	}
+
+	invite := invites[0]
+	invite.Expiry = &time.Time{}
+
+	// reset fields for reflect.DeepEqual
+	invite.Model = gorm.Model{}
+
+	if diff := deep.Equal(expInvite, *invite); diff != nil {
+		t.Errorf("incorrect invite")
+		t.Error(diff)
+	}
+}

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