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

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
 }
+
+// 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
 }
 
+// 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
 type ListRegistryResponse []models.RegistryExternal
 
@@ -243,6 +290,46 @@ func (c *Client) GetGCRAuthorizationToken(
 	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
 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{
 	Use:     "helmrepo",
 	Aliases: []string{"helm", "helmrepos"},
@@ -103,6 +115,7 @@ func init() {
 
 	connectCmd.AddCommand(connectECRCmd)
 	connectCmd.AddCommand(connectGCRCmd)
+	connectCmd.AddCommand(connectDOCRCmd)
 	connectCmd.AddCommand(connectHRCmd)
 }
 
@@ -154,6 +167,19 @@ func runConnectGCR(_ *api.AuthCheckResponse, client *api.Client, _ []string) err
 	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 {
 	hrID, err := connect.Helm(
 		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"
 	"net/url"
 	"os"
+	"os/exec"
 	"path/filepath"
 	"strings"
 
+	"github.com/fatih/color"
 	"github.com/porter-dev/porter/cli/cmd/api"
 	"github.com/porter-dev/porter/cli/cmd/github"
 	"github.com/spf13/cobra"
@@ -93,21 +95,30 @@ func dockerConfig(user *api.AuthCheckResponse, client *api.Client, args []string
 		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{
@@ -126,3 +137,23 @@ func dockerConfig(user *api.AuthCheckResponse, client *api.Client, args []string
 
 	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,
 	}
 
-	return zStatic.GetLatestRelease()
+	return zStatic.GetRelease(Version)
 }
 
 type versionWriter struct {

+ 1 - 1
cli/cmd/version.go

@@ -7,7 +7,7 @@ import (
 )
 
 // 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{
 	Use:     "version",

+ 1 - 0
cmd/app/main.go

@@ -57,6 +57,7 @@ func main() {
 		&models.ClusterCandidate{},
 		&models.ClusterResolver{},
 		&models.Infra{},
+		&models.Invite{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&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") {
 		return p.getGCR(serverURL)
+	} else if strings.Contains(serverURL, "registry.digitalocean.com") {
+		return p.getDOCR(serverURL)
 	}
 
 	return p.getECR(serverURL)
 }
 
 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 {
 		return "", "", err
@@ -96,6 +98,72 @@ func (p *PorterHelper) getGCR(serverURL string) (user string, secret string, err
 	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) {
 	// parse the server url for region
 	matches := ecrPattern.FindStringSubmatch(serverURL)

+ 1 - 0
cmd/migrate/main.go

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

+ 13 - 0
dashboard/package-lock.json

@@ -2944,6 +2944,11 @@
         "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": {
       "version": "3.5.2",
       "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",
       "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": {
       "version": "1.0.6",
       "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",
     "lodash": "^4.17.20",
     "markdown-to-jsx": "^7.0.1",
-    "posthog-node": "^1.0.6",
+    "posthog-js": "^1.8.5",
     "qs": "^6.9.4",
     "random-words": "^1.1.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,
     total?: number,
   } | null
+  expanded?: boolean,
 };
 
 type StateType = {
@@ -24,7 +25,7 @@ type StateType = {
 
 export default class ResourceTab extends Component<PropsType, StateType> {
   state = {
-    expanded: false,
+    expanded: this.props.expanded || 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() {
     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 = atob(value);
     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 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;
   font-weight: 500;
   font-size: 16px;
-  margin-top: 30px;
+  margin-top: ${props => props.isAtTop ? '0': '30px'};
   margin-bottom: 5px;
   display: flex;
   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}
             />
           );
+        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':
           return (
             <InputRow
@@ -155,7 +169,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
             <Base64InputRow
               key={i}
               isRequired={item.required}
-              type='b64'
+              type='text'
               value={this.getInputValue(item)}
               setValue={(x: string) => {
                 if (item.settings && item.settings.unit && x !== '') {
@@ -172,7 +186,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
             <Base64InputRow
               key={i}
               isRequired={item.required}
-              type='b64-pass'
+              type='password'
               value={this.getInputValue(item)}
               setValue={(x: string) => {
                 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':
                 metaState[key] = def ? def : '';
                 break;
+              case 'array-input':
+                metaState[key] = def ? def : [];
+                break;
               case 'number-input':
                 metaState[key] = def.toString() ? def : '';
                 break;
               case 'select':
                 metaState[key] = def ? def : item.settings.options[0].value;
                 break;
+              case 'base-64':
+                metaState[key] = def ? def : '';
+              case 'base-64-password':
+                metaState[key] = def ? def : '';
               default:
             }
           });

+ 10 - 0
dashboard/src/index.html

@@ -19,5 +19,15 @@
   </head>
 <body>
   <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>
 </html>

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

@@ -5,6 +5,7 @@ import close from '../assets/close.png';
 import { Context } from '../shared/Context';
 
 type PropsType = {
+  currentError: string,
 };
 
 type StateType = {
@@ -12,15 +13,24 @@ type StateType = {
 
 export default class CurrentError extends Component<PropsType, StateType> {
   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() {
-    if (this.context.currentError) {
+    if (this.props.currentError) {
       if (!this.state.expanded) {
         return (
           <StyledCurrentError onClick={() => this.setState({ expanded: true })}>
-            <ErrorText>Error: {this.context.currentError}</ErrorText>
+            <ErrorText>Error: {this.props.currentError}</ErrorText>
             <CloseButton onClick={(e) => {
               this.context.setCurrentError(null);
               e.stopPropagation();
@@ -33,7 +43,7 @@ export default class CurrentError extends Component<PropsType, StateType> {
 
       return (
         <ExpandedError onClick={() => this.setState({ expanded: false })}>
-          Error: {this.context.currentError}
+          Error: {this.props.currentError}
           <CloseButtonAlt onClick={() => this.context.setCurrentError(null)}>
             <CloseButtonImg src={close} />
           </CloseButtonAlt>
@@ -80,9 +90,9 @@ const ErrorText = styled.div`
 
 const StyledCurrentError = styled.div`
   position: fixed;
-  bottom: 20px;
+  bottom: 22px;
   width: 300px;
-  left: 17px;
+  left: 100px;
   padding: 15px;
   padding-right: 0px;
   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 => {
     let { email, password } = this.state;
     let { authenticate } = this.props;
-    let { setCurrentError, setUser } = this.context;
+    let { setUser } = this.context;
 
     // Check for valid input
     if (!emailRegex.test(email)) {
@@ -55,8 +55,9 @@ export default class Login extends Component<PropsType, StateType> {
         password: password
       }, {}, (err: any, res: any) => {
         // TODO: case and set credential error
+        console.log(res.data);
         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() {
     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 })
       }
 
@@ -55,6 +55,9 @@ export default class Main extends Component<PropsType, StateType> {
   }
 
   handleLogOut = () => {
+    // Clears local storage for proper rendering of clusters
+    localStorage.clear();
+
     this.context.clearContext();
     this.setState({ isLoggedIn: false, initialized: true });
   }
@@ -84,7 +87,13 @@ export default class Main extends Component<PropsType, StateType> {
 
         <Route path='/dashboard' render={() => {
           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 {
             return <Redirect to='/' />
           }
@@ -110,7 +119,7 @@ export default class Main extends Component<PropsType, StateType> {
         <BrowserRouter>
           {this.renderMain()}
         </BrowserRouter>
-        <CurrentError />
+        <CurrentError currentError={this.context.currentError} />
       </StyledMain>
     );
   }

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

@@ -1,10 +1,12 @@
 import React, { Component } from 'react';
+import posthog from 'posthog-js';
 import styled from 'styled-components';
 import ReactModal from 'react-modal';
 
 import { Context } from '../../shared/Context';
 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 Dashboard from './dashboard/Dashboard';
@@ -12,24 +14,26 @@ import ClusterDashboard from './cluster-dashboard/ClusterDashboard';
 import Loading from '../../components/Loading';
 import Templates from './templates/Templates';
 import Integrations from "./integrations/Integrations";
-import UpdateProjectModal from './modals/UpdateProjectModal';
 import UpdateClusterModal from './modals/UpdateClusterModal';
 import ClusterInstructionsModal from './modals/ClusterInstructionsModal';
 import IntegrationsModal from './modals/IntegrationsModal';
 import IntegrationsInstructionsModal from './modals/IntegrationsInstructionsModal';
 import NewProject from './new-project/NewProject';
 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 = {
-  logOut: () => void
+  logOut: () => void,
+  currentProject: ProjectType,
+  currentCluster: ClusterType,
 };
 
 type StateType = {
   forceSidebar: boolean,
   showWelcome: boolean,
   currentView: string,
-  viewData: any[],
   forceRefreshClusters: boolean, // For updating ClusterSection from modal on deletion
 
   // 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
 };
 
+// TODO: Handle cluster connected but with some failed infras (no successful set)
 export default class Home extends Component<PropsType, StateType> {
   state = {
     forceSidebar: true,
     showWelcome: false,
     currentView: 'dashboard',
     prevProjectId: null as number | null,
-    viewData: null as any,
     forceRefreshClusters: 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 = () => {
-    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) => {
       if (err) {
         console.log(err);
       } 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]);
 
-          // 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() {
+    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();
   }
 
   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
   renderDashboard = () => {
     let { currentCluster, setCurrentModal } = this.context;
-    if (this.state.showWelcome || currentCluster && !currentCluster.name) {
+    if (currentCluster && !currentCluster.name) {
       return (
         <DashboardWrapper>
           <Placeholder>
@@ -147,22 +145,28 @@ export default class Home extends Component<PropsType, StateType> {
     } else if (currentView === 'dashboard') {
       return (
         <DashboardWrapper>
-          <Dashboard setCurrentView={(x: string) => this.setState({ currentView: x })} />
+          <Dashboard 
+            setCurrentView={(x: string) => this.setState({ currentView: x })}
+            projectId={this.context.currentProject?.id}
+          />
         </DashboardWrapper>
       );
     } else if (currentView === 'integrations') {
       return <Integrations />;
     } else if (currentView === 'new-project') {
       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') {
       return (
-        <Provisioner 
+        <ProvisionerStatus
           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 (
@@ -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 {
-      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
       if (this.state.currentView === 'provisioner' && this.state.forceSidebar) {
         this.setState({ forceSidebar: false });
-      } else if (this.state.sidebarReady) {
+      } else {
         return (
           <Sidebar
             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() {
     let { currentModal, setCurrentModal, currentProject } = this.context;
     return (
@@ -213,14 +272,6 @@ export default class Home extends Component<PropsType, StateType> {
         >
           <ClusterInstructionsModal />
         </ReactModal>
-        <ReactModal
-          isOpen={currentModal === 'UpdateProjectModal'}
-          onRequestClose={() => setCurrentModal(null, null)}
-          style={ProjectModalStyles}
-          ariaHideApp={false}
-        >
-          <UpdateProjectModal />
-        </ReactModal>
         <ReactModal
           isOpen={currentModal === 'UpdateClusterModal'}
           onRequestClose={() => setCurrentModal(null, null)}
@@ -257,6 +308,13 @@ export default class Home extends Component<PropsType, StateType> {
           />
           {this.renderContents()}
         </ViewWrapper>
+
+        <ConfirmOverlay
+          show={currentModal === 'UpdateProjectModal'}
+          message={(currentProject) ? `Are you sure you want to delete ${currentProject.name}?` : ''}
+          onYes={this.handleDelete}
+          onNo={() => setCurrentModal(null, null)}
+        />
       </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> {
   state = {
     namespace: 'default',
-    sortType: 'Newest',
+    sortType: (localStorage.getItem("SortType") ? localStorage.getItem('SortType') : 'Newest'),
     currentChart: null as (ChartType | null)
   }
 
-  componentDidMount() {
-    if (localStorage.getItem("SortType")) {
-      this.setState({ sortType: localStorage.getItem("SortType") });
-    }
-  }
-
   componentDidUpdate(prevProps: PropsType) {
     localStorage.setItem("SortType", this.state.sortType);
     // Reset namespace filter and close expanded chart on cluster change
     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") {
           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") {
-          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({ loading: false, error: false });
@@ -71,6 +71,7 @@ export default class ChartList extends Component<PropsType, StateType> {
 
   setupWebsocket = (kind: string) => {
       let { currentCluster, currentProject } = this.context;
+      console.log(currentCluster)
       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}`);
       ws.onopen = () => {
@@ -234,16 +235,22 @@ export default class ChartList extends Component<PropsType, StateType> {
 ChartList.contextType = Context;
 
 const Placeholder = styled.div`
-  padding-top: 100px;
   width: 100%;
   display: flex;
   justify-content: center;
   align-items: center;
   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 {
-    font-size: 18px;
+    font-size: 16px;
     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 SettingsSection from './SettingsSection';
 import ConfirmOverlay from '../../../../components/ConfirmOverlay';
+import Loading from '../../../../components/Loading';
 
 type PropsType = {
   namespace: string,
@@ -45,6 +46,7 @@ type StateType = {
   websockets: Record<string, any>,
   url: string | null,
   showDeleteOverlay: boolean,
+  deleting: boolean,
 };
 
 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>,
     url: null as string | null,
     showDeleteOverlay: false,
+    deleting: false,
   }
 
   // Retrieve full chart data (includes form and values)
@@ -457,7 +460,6 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
         console.log(err)
       } else {
         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);
         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) {
-        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) {
@@ -490,8 +499,8 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
   componentWillUnmount() {
     if (this.state.websockets) {
       this.state.websockets.forEach((ws: WebSocket) => {
-        ws.close()
-      })
+        ws.close();
+      });
     }
   }
 
@@ -517,7 +526,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
       return (
         <Url>
           <Bolded>Internal URI:</Bolded>
-          {`${serviceName}.${serviceNamespace}.namespace.svc.cluster.local`}
+          {`${serviceName}.${serviceNamespace}.svc.cluster.local`}
         </Url>
       );
     }
@@ -526,12 +535,11 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
   handleUninstallChart = () => {
     let { currentProject, currentCluster } = this.context;
     let { currentChart } = this.props;
-    console.log('here', currentChart.namespace, StorageType.Secret)
+    this.setState({ deleting: true });
     api.uninstallTemplate('<token>', {
+    }, {
       namespace: currentChart.namespace,
-      cluster_id: currentCluster.id,
       storage: StorageType.Secret,
-    }, {
       name: currentChart.name,
       id: currentProject.id,
       cluster_id: currentCluster.id,
@@ -539,11 +547,18 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
       if (err) {
         console.log(err)
       } else {
-        console.log('worked i guess');
+        this.setState({ showDeleteOverlay: false });
+        this.props.setCurrentChart(null);
       }
     });
   }
 
+  renderDeleteOverlay = () => {
+    if (this.state.deleting) {
+      return <DeleteOverlay><Loading /></DeleteOverlay>;
+    }
+  }
+
   render() {
     let { currentChart, setCurrentChart } = this.props;
     let chart = currentChart;
@@ -559,6 +574,8 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
             onYes={this.handleUninstallChart}
             onNo={() => this.setState({ showDeleteOverlay: false })}
           />
+          {this.renderDeleteOverlay()}
+          
           <HeaderWrapper>
             <TitleSection>
               <Title>
@@ -619,6 +636,34 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
 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`
   font-weight: 500;
   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 Edge from './Edge';
 import InfoPanel from './InfoPanel';
+import ZoomPanel from './ZoomPanel';
 import SelectRegion from './SelectRegion';
 
 const zoomConstant = 0.01;
@@ -41,6 +42,7 @@ type StateType = {
   preventBgDrag: boolean, // Prevent bg drag when moving selected with mouse down
   relocateAllowed: boolean, // Suppress movement of selected when drawing select region
   scale: number,
+  btnZooming: boolean,
   showKindLabels: boolean,
   isExpanded: boolean,
   currentNode: NodeType | null,
@@ -73,6 +75,7 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
     preventBgDrag: false,
     relocateAllowed: false,
     scale: 0.5,
+    btnZooming: false,
     showKindLabels: true,
     isExpanded: false,
     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)
   handleWheel = (e: any) => {
+    this.setState({ btnZooming: false });
 
     // Prevent nav gestures if mouse is over InfoPanel or ButtonSection
     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 = () => {
     this.setState({ isExpanded: !this.state.isExpanded }, () => {
       this.props.setSidebar(!this.state.isExpanded);
@@ -385,8 +397,21 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
   renderNodes = () => {
     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
       if (activeIds.includes(node.id) && relocateAllowed && !anchorX && !anchorY) {
         node.x = cursorX + node.toCursorX;
@@ -401,8 +426,14 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
 
       // Apply cursor-centered zoom
       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 
@@ -510,6 +541,10 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
           isExpanded={this.state.isExpanded}
           showRevisions={this.props.showRevisions}
         />
+        <ZoomPanel
+          btnZoomIn={this.btnZoomIn}
+          btnZoomOut={this.btnZoomOut}
+        />
       </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,
   selectPod: Function,
   isLast?: boolean,
+  isFirst?: boolean,
 };
 
 type StateType = {
@@ -26,7 +27,7 @@ export default class ControllerTab extends Component<PropsType, StateType> {
 
   componentDidMount() {
     let { currentCluster, currentProject, setCurrentError } = this.context;
-    let { controller } = this.props;
+    let { controller, selectPod, isFirst } = this.props;
 
     let selectors = [] as string[];
     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 });
+      
+      if (isFirst) {
+        selectPod(res.data[0])
+      }
     })
   }
 
@@ -80,7 +85,7 @@ export default class ControllerTab extends Component<PropsType, StateType> {
   }
 
   getPodStatus = (status: any) => {
-    if (status?.phase == 'Pending' && status?.containerStatuses) {
+    if (status?.phase == 'Pending' && status?.containerStatuses !== undefined) {
       return status.containerStatuses[0].state.waiting.reason
       // return 'waiting'
     }
@@ -104,17 +109,16 @@ export default class ControllerTab extends Component<PropsType, StateType> {
   }
 
   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 status = (available == total) ? 'running' : 'waiting'
-    console.log('state', this.state)
     return (
       <ResourceTab
         label={controller.kind}
         name={controller.metadata.name}
         status={{ label: status, available, total }}
         isLast={isLast}
+        expanded={isFirst}
       >
         {
           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 = {
   logs: string[],
-  ws: any
+  ws: any,
+  scroll: boolean,
 };
 
 export default class Logs extends Component<PropsType, StateType> {
   
   state = {
     logs: [] as string[],
-    ws : null as any
+    ws : null as any,
+    scroll: true,
   }
 
+  ws = null as any;
   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 = () => {
@@ -37,44 +45,78 @@ export default class Logs extends Component<PropsType, StateType> {
     })
   }
 
-  componentDidMount() {
+  setupWebsocket = () => {  
     let { currentCluster, currentProject } = this.context;
     let { selectedPod } = this.props;
     if (!selectedPod.metadata?.name) return
     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() {
-    if (this.state.ws) {
-      this.state.ws.close()
+    console.log('log unmount')
+    if (this.ws) {
+      this.ws.close()
     }
   }
 
   render() {
     return (
-      <LogStream ref={this.scrollRef}>
-        <Wrapper>
+      <LogStream>
+        <Wrapper ref={this.parentRef}>
           {this.renderLogs()}
+          <div ref={this.scrollRef} />
         </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>
     );
   }
@@ -82,6 +124,54 @@ export default class Logs extends Component<PropsType, StateType> {
 
 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`
   width: 100%;
   height: 100%;
@@ -91,6 +181,7 @@ const Wrapper = styled.div`
 
 const LogStream = styled.div`
   display: flex;
+  flex-direction: column;
   flex: 1;
   float: right;
   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)}
           controller={c}
           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 styled from 'styled-components';
-import gradient from '../../../assets/gradient.jpg';
 
+import gradient from '../../../assets/gradient.jpg';
 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 = {
   setCurrentView: (x: string) => void,
+  projectId: number | null,
 };
 
 type StateType = {
+  infras: InfraType[],
 };
 
 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() {
+    let { currentProject, currentCluster } = this.context;
+    let { setCurrentView } = this.props;
+    let { infras } = this.state;
+    let { onShowProjectSettings } = this;
     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;
 
-const Placeholder = styled.div`
+const DashboardWrapper = styled.div`
+  padding-bottom: 100px;
+`;
+
+const Banner = styled.div`
+  height: 40px;
   width: 100%;
-  height: calc(100vh - 380px);
-  margin-top: 30px;
+  margin: 10px 0 30px;
+  font-size: 13px;
   display: flex;
-  padding-bottom: 20px;
-  align-items: center;
-  justify-content: center;
-  color: #aaaabb;
   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`
@@ -119,56 +166,6 @@ const InfoSection = styled.div`
   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`
   width: calc(100% - 0px);
   height: 2px;

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

@@ -8,6 +8,7 @@ import api from '../../../shared/api';
 type PropsType = {
   setCurrent: (x: any) => void,
   integrations: string[],
+  titles?: string[],
   isCategory?: boolean
 };
 
@@ -16,8 +17,32 @@ type StateType = {
 
 export default class IntegrationList extends Component<PropsType, StateType> {
   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) => {
         let icon = integrationList[integration] && integrationList[integration].icon;
         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`
   color: #ffffff;
   font-size: 14px;
   font-weight: 500;
 `;
 
+const Subtitle = styled.div`
+  color: #aaaabb;
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  padding-top: 5px;
+`;
+
 const Icon = styled.img`
   width: 30px;
   margin-right: 18px;

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

@@ -16,6 +16,7 @@ type StateType = {
   currentCategory: string | null,
   currentIntegration: string | null,
   currentOptions: any[],
+  currentTitles: any[],
   currentIntegrationData: any[],
 };
 
@@ -24,6 +25,7 @@ export default class Integrations extends Component<PropsType, StateType> {
     currentCategory: null as string | null,
     currentIntegration: null as string | null,
     currentOptions: [] as any[],
+    currentTitles: [] as any[],
     currentIntegrationData: [] as any[],
   }
 
@@ -45,11 +47,25 @@ export default class Integrations extends Component<PropsType, StateType> {
           if (err) {
             console.log(err);
           } 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[];
-            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;
@@ -150,8 +166,11 @@ export default class Integrations extends Component<PropsType, StateType> {
             </Button>
           </TitleSectionAlt>
 
+          <LineBreak />
+
           <IntegrationList
             integrations={this.state.currentOptions}
+            titles={this.state.currentTitles}
             setCurrent={(x: string) => this.setState({ currentIntegration: x })}
           />
         </div>
@@ -293,4 +312,11 @@ const StyledIntegrations = 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: 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 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 { integrationList } from '../../../shared/common';
-import { ProjectType } from '../../../shared/types';
+import { isAlphanumeric } from '../../../shared/common';
 
 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';
-
-const providers = ['aws', 'gcp', 'do',];
+import ProvisionerSettings from '../provisioner/ProvisionerSettings';
 
 type PropsType = {
   setCurrentView: (x: string, data?: any) => void,
 };
 
 type StateType = {
-  projectExists: boolean,
   projectName: string,
   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> {
   state = {
-    projectExists: false,
     projectName: '',
     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 (
-      <>
+      <StyledNewProject>
         <TitleSection>
           <Title>New Project</Title>
         </TitleSection>
         <Helper>
           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)
           </Warning>
           <Required>*</Required>
@@ -477,43 +52,12 @@ export default class NewProject extends Component<PropsType, StateType> {
             width='470px'
           />
         </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>
     );
   }
@@ -521,6 +65,11 @@ export default class NewProject extends Component<PropsType, StateType> {
 
 NewProject.contextType = Context;
 
+const Br = styled.div`
+  width: 100%;
+  height: 100px;
+`;
+
 const Link = styled.a`
   cursor: pointer;
   margin-left: 5px;
@@ -796,8 +345,7 @@ const TitleSection = styled.div`
 const StyledNewProject = styled.div`
   width: calc(90% - 150px);
   min-width: 300px;
-  height: ${(props: { height: string }) => props.height};
   position: relative;
   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 loading from '../../../assets/loading.gif';
 import warning from '../../../assets/warning.png';
+import { InfraType } from '../../../shared/types';
+import { filterOldInfras } from '../../../shared/common';
 
 import Helper from '../../../components/values-form/Helper';
-import { eventNames } from 'process';
-import { inflateRaw, inflateRawSync } from 'zlib';
+import InfraStatuses from './InfraStatuses';
 
 type PropsType = {
-  viewData: any,
   setCurrentView: (x: string) => void,
 }
 
@@ -23,8 +23,17 @@ type StateType = {
   maxStep : Record<string, number>,
   currentStep: Record<string, number>,
   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> {
   state = {
     error: false,
@@ -33,6 +42,44 @@ export default class Provisioner extends Component<PropsType, StateType> {
     maxStep: {} as Record<string, any>,
     currentStep: {} as Record<string, number>,
     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 = () => {
@@ -127,69 +174,19 @@ export default class Provisioner extends Component<PropsType, StateType> {
     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>();
 
   renderLogs = () => {
     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 = () => {
     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) {
           console.log(err);
         } else if (res.data) {
@@ -204,6 +201,9 @@ export default class Provisioner extends Component<PropsType, StateType> {
   }
   
   render() {
+    let { error, triggerEnd, infras } = this.state;
+    let { setCurrentView } = this.props;
+    
     let maxStep = 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.setState({ triggerEnd: true });
     }
 
     return (
       <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>
-          <Loaded progress={((currentStep / (maxStep == 0 ? 1 : maxStep)) * 100).toString() + '%'} />
+          <Loaded 
+            progress={
+              error ? (
+                '0%'
+              ) : (
+                (((currentStep / (maxStep == 0 ? 1 : maxStep)) * 100).toString() + '%')
+              )
+            }
+          />
         </LoadingBar>
+        <InfraStatuses infras={infras} />
 
         <LogStream ref={this.scrollRef}>
-          <Wrapper>
-            {this.renderLogs()}
-          </Wrapper>
+          <Wrapper>{this.renderLogs()}</Wrapper>
         </LogStream>
 
         <Helper>
@@ -273,7 +305,7 @@ const Log = styled.div`
 
 const LogStream = styled.div`
   height: 300px;
-  margin-top: 30px;
+  margin-top: 20px;
   font-size: 13px;
   border: 2px solid #ffffff55;
   border-radius: 10px;
@@ -292,8 +324,8 @@ const Message = styled.div`
   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%;
   background: linear-gradient(to right, #4f8aff, #8e7dff, #4f8aff);
   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);
       } else {
         this.props.setWelcome(false);
-        
         // TODO: handle uninitialized kubeconfig
         if (res.data) {
+          console.log(res.data);
           let clusters = res.data;
+          clusters.sort((a: any, b: any) => a.id - b.id);
           if (clusters.length > 0) {
             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: [] });
             setCurrentCluster(null);
             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;
 
     if (clusters.length > 0 && currentCluster) {
+      clusters.sort((a, b) => a.id - b.id);
+      
       return clusters.map((cluster: ClusterType, i: number) => {
         /*
         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 gradient from '../../../assets/gradient.jpg';
 
-import api from '../../../shared/api';
 import { Context } from '../../../shared/Context';
 import { ProjectType, InfraType } from '../../../shared/types';
 
 type PropsType = {
   currentProject: ProjectType,
-  setCurrentView: (x: string, viewData?: any) => void,
+  setCurrentView: (x: string) => void,
   projects: ProjectType[],
 };
 
@@ -21,40 +20,15 @@ export default class ProjectSection extends Component<PropsType, StateType> {
     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 = () => {
+    let { setCurrentProject } = this.context;
+
     return this.props.projects.map((project: ProjectType, i: number) => {
       return (
         <Option
           key={i}
           selected={project.name === this.props.currentProject.name}
-          onClick={() => this.handleSelectProject(project)}
+          onClick={() => setCurrentProject(project)}
         >
           <ProjectIcon>
             <ProjectImage src={gradient} />
@@ -170,7 +144,7 @@ const Option = styled.div`
   font-size: 13px;
   align-items: center;
   padding-left: 10px;
-  cursor: ${(props: { selected: boolean, lastItem?: boolean }) => props.selected ? '' : 'pointer'};
+  cursor: pointer;
   padding-right: 10px;
   background: ${(props: { selected: boolean, lastItem?: boolean }) => props.selected ? '#ffffff11' : ''};
   :hover {

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

@@ -5,7 +5,7 @@ import { Context } from '../../../shared/Context';
 import ProjectSection from './ProjectSection';
 
 type PropsType = {
-  setCurrentView: (x: string, viewData?: any) => void,
+  setCurrentView: (x: string) => void,
 };
 
 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 integrations from '../../../assets/integrations.svg';
 import filter from '../../../assets/filter.svg';
+import settings from '../../../assets/settings.svg';
 
 import { Context } from '../../../shared/Context';
 
 import ClusterSection from './ClusterSection';
 import ProjectSectionContainer from './ProjectSectionContainer';
 import loading from '../../../assets/loading.gif';
+import posthog from 'posthog-js';
 
 type PropsType = {
   forceSidebar: boolean,
   setWelcome: (x: boolean) => void,
-  setCurrentView: (x: string, viewData?: any) => void,
+  setCurrentView: (x: string) => void,
   currentView: string,
   forceRefreshClusters: boolean,
   setRefreshClusters: (x: boolean) => void,
@@ -92,38 +94,46 @@ export default class Sidebar extends Component<PropsType, StateType> {
   };
 
   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 (
         <>
           <SidebarLabel>Home</SidebarLabel>
           <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
           </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
           </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
           </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 />
 
@@ -132,9 +142,9 @@ export default class Sidebar extends Component<PropsType, StateType> {
             forceCloseDrawer={this.state.forceCloseDrawer} 
             releaseDrawer={() => this.setState({ forceCloseDrawer: false })}
             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}
             setRefreshClusters={this.props.setRefreshClusters}
           />
@@ -153,7 +163,7 @@ export default class Sidebar extends Component<PropsType, StateType> {
   // SidebarBg is separate to cover retracted drawer
   render() {
     return (
-      <div>
+      <>
         {this.renderPullTab()}
         <StyledSidebar showSidebar={this.state.showSidebar}>
           <SidebarBg />
@@ -174,7 +184,7 @@ export default class Sidebar extends Component<PropsType, StateType> {
 
           {this.renderProjectContents()}
         </StyledSidebar>
-      </div>
+      </>
     );
   }
 }
@@ -230,16 +240,16 @@ const NavButton = styled.div`
     left: 19px;
     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`

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

@@ -17,6 +17,7 @@ const tabOptions = [
 const hardcodedNames: any = {
   'postgresql': 'PostgreSQL',
   'docker': 'Docker',
+  'https-issuer': 'HTTPS Issuer'
 };
 
 type PropsType = {
@@ -45,7 +46,11 @@ export default class Templates extends Component<PropsType, StateType> {
       if (err) {
         this.setState({ loading: false, error: true });
       } 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 name = randomWords({ exactly: 3, join: '-' });
     this.setState({ saveValuesStatus: 'loading' });
+
+    let values = {};
+    for (let key in wildcard) {
+      _.set(values, key, wildcard[key]);
+    }
+
     api.deployTemplate('<token>', {
       templateName: this.props.currentTemplate.name,
       storage: StorageType.Secret,
+      formValues: values,
       namespace: this.state.selectedNamespace,
       name,
     }, {
@@ -92,6 +99,8 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
       let splits = this.state.selectedImageUrl.split(':');
       imageUrl = splits[0];
       tag = splits[1];
+    } else if (!tag) {
+      tag = 'latest';
     }
 
     _.set(values, "image.repository", imageUrl)

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

@@ -49,7 +49,7 @@ class ContextProvider extends Component {
     },
     user: null as any,
     setUser: (userId: number, email: string) => {
-      this.setState({ user: {userId, email} });
+      this.setState({ user: { userId, email } });
     },
     devOpsMode: true,
     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}`;
 });
 
+const getInvites = baseApi<{}, { id: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/invites`;
+});
+
 const getRevisions = baseApi<{
   namespace: string,
   cluster_id: number,
@@ -173,6 +177,10 @@ const deleteProject = baseApi<{}, { id: number }>('DELETE', pathParams => {
   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<{
   templateName: string,
   imageURL?: string,
@@ -191,15 +199,15 @@ const deployTemplate = baseApi<{
 });
 
 const uninstallTemplate = baseApi<{
-  namespace: string,
-  cluster_id: number,
-  storage: StorageType,
 }, {
   id: number,
   name: string, 
   cluster_id: number,
+  namespace: string,
+  storage: StorageType,
 }>('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');
@@ -332,54 +340,65 @@ const createGKE = baseApi<{
   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)
 export default {
-  uninstallTemplate,
+  checkAuth,
+  createAWSIntegration,
+  createECR,
+  createGCPIntegration,
   createGCR,
   createGKE,
-  createGCPIntegration,
+  createInvite,
+  createProject,
   deleteCluster,
+  deleteInvite,
+  deleteProject,
+  deployTemplate,
   destroyCluster,
-  getInfra,
-  linkGithubProject,
-  getGitRepos,
-  checkAuth,
-  registerUser,
-  logInUser,
-  logOutUser,
-  getRepos,
-  getUser,
-  updateUser,
-  getClusters,
-  getCharts,
+  getBranchContents,
+  getBranches,
   getChart,
+  getCharts,
   getChartComponents,
   getChartControllers,
-  getNamespaces,
-  getMatchingPods,
-  getIngress,
-  getRevisions,
-  rollbackChart,
-  upgradeChartValues,
-  getTemplates,
-  getTemplateInfo,
-  getBranches,
-  getBranchContents,
-  getProjects,
-  getReleaseToken,
-  createProject,
-  deleteProject,
-  deployTemplate,
   getClusterIntegrations,
-  getRegistryIntegrations,
-  getRepoIntegrations,
+  getClusters,
+  getGitRepos,
+  getImageRepos,
+  getImageTags,
+  getInfra,
+  getIngress,
+  getInvites,
+  getMatchingPods,
+  getNamespaces,
   getProjectClusters,
   getProjectRegistries,
   getProjectRepos,
-  createAWSIntegration,
+  getProjects,
+  getRegistryIntegrations,
+  getReleaseToken,
+  getRepoIntegrations,
+  getRepos,
+  getRevisions,
+  getTemplateInfo,
+  getTemplates,
+  getUser,
+  linkGithubProject,
+  logInUser,
+  logOutUser,
   provisionECR,
   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 => {
+        console.log('from axio')
+        console.log(res.data);
         callback && callback(null, res);
       })
       .catch(err => {

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

@@ -1,6 +1,16 @@
 import aws from '../assets/aws.png';
 import digitalOcean from '../assets/do.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 = {
   '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) => {
   return object[Object.keys(object)
     .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 {
   id: number,
-  project_d: number,
+  project_id: number,
   kind: 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/coreos/rkt v1.30.0
 	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/distribution v2.7.1+incompatible
 	github.com/docker/docker v1.4.2-0.20200203170920-46ec8731fbce
 	github.com/docker/docker-credential-helpers v0.6.3
 	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/v8 v8.3.1
 	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-querystring v1.0.0 // 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/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
 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/docker/cli v0.0.0-20200130152716-5d0cf8839492 h1:FwssHbCDJD025h+BchanCwE1Q8fyMgqDr2mOQAWOLGw=
 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.18.8 h1:SdbLpIxk5j5YbFr1b7fq8S7mDgDjYmUxSbszyoesoDM=
 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.18.8/go.mod h1:TgNEVx9hCyPGpdtCWA34olQYLkh3ok9ar7XfSsr8b6c=
 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"`
 	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"`
 	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,

+ 68 - 0
internal/forms/infra.go

@@ -9,6 +9,22 @@ import (
 
 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
 // ECR infra via the provisioning container
 type CreateECRInfra struct {
@@ -84,6 +100,46 @@ func (ce *CreateGKEInfra) ToInfra() (*models.Infra, error) {
 	}, 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
 // ECR infra via the provisioning container
 type DestroyECRInfra struct {
@@ -102,6 +158,18 @@ type DestroyGKEInfra struct {
 	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
 var seededRand *rand.Rand = rand.New(
 	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"`
 	GCPIntegrationID uint   `json:"gcp_integration_id"`
 	AWSIntegrationID uint   `json:"aws_integration_id"`
+	DOIntegrationID  uint   `json:"do_integration_id"`
 }
 
 // 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,
 		GCPIntegrationID: cr.GCPIntegrationID,
 		AWSIntegrationID: cr.AWSIntegrationID,
+		DOIntegrationID:  cr.DOIntegrationID,
 	}
 
 	if registry.URL == "" && registry.AWSIntegrationID != 0 {

+ 5 - 1
internal/forms/release.go

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

+ 65 - 14
internal/helm/agent.go

@@ -4,6 +4,10 @@ import (
 	"fmt"
 
 	"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/chart"
 	"helm.sh/helm/v3/pkg/release"
@@ -13,6 +17,7 @@ import (
 // Agent is a Helm agent for performing helm operations
 type Agent struct {
 	ActionConfig *action.Configuration
+	K8sAgent     *kubernetes.Agent
 }
 
 // ListReleases lists releases based on a ListFilter
@@ -49,10 +54,19 @@ func (a *Agent) GetReleaseHistory(
 	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
 func (a *Agent) UpgradeRelease(
-	name string,
+	conf *UpgradeReleaseConfig,
 	values string,
+	doAuth *oauth2.Config,
 ) (*release.Release, error) {
 	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 a.UpgradeReleaseByValues(name, valuesYaml)
+	conf.Values = valuesYaml
+
+	return a.UpgradeReleaseByValues(conf, doAuth)
 }
 
 // UpgradeReleaseByValues upgrades a release by unmarshaled yaml values
 func (a *Agent) UpgradeReleaseByValues(
-	name string,
-	values map[string]interface{},
+	conf *UpgradeReleaseConfig,
+	doAuth *oauth2.Config,
 ) (*release.Release, error) {
 	// grab the latest release
-	rel, err := a.GetRelease(name, 0)
+	rel, err := a.GetRelease(conf.Name, 0)
 
 	if err != nil {
 		return nil, fmt.Errorf("Could not get release to be upgraded: %v", err)
@@ -78,7 +94,23 @@ func (a *Agent) UpgradeReleaseByValues(
 	ch := rel.Chart
 
 	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 {
 		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
 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
 func (a *Agent) InstallChartFromValuesBytes(
 	conf *InstallChartConfig,
 	values []byte,
+	doAuth *oauth2.Config,
 ) (*release.Release, error) {
 	valuesYaml, err := chartutil.ReadValues(values)
 
@@ -108,12 +144,13 @@ func (a *Agent) InstallChartFromValuesBytes(
 
 	conf.Values = valuesYaml
 
-	return a.InstallChart(conf)
+	return a.InstallChart(conf, doAuth)
 }
 
 // InstallChart installs a new chart
 func (a *Agent) InstallChart(
 	conf *InstallChartConfig,
+	doAuth *oauth2.Config,
 ) (*release.Release, error) {
 	cmd := action.NewInstall(a.ActionConfig)
 
@@ -128,9 +165,23 @@ func (a *Agent) InstallChart(
 		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 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/models"
 	"github.com/porter-dev/porter/internal/repository"
+	"golang.org/x/oauth2"
 	"helm.sh/helm/v3/pkg/action"
 	"helm.sh/helm/v3/pkg/chartutil"
 	"helm.sh/helm/v3/pkg/kube"
@@ -19,10 +20,11 @@ import (
 // Form represents the options for connecting to a cluster and
 // creating a Helm agent
 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
@@ -30,8 +32,9 @@ type Form struct {
 func GetAgentOutOfClusterConfig(form *Form, l *logger.Logger) (*Agent, error) {
 	// create a kubernetes agent
 	conf := &kubernetes.OutOfClusterConfig{
-		Cluster: form.Cluster,
-		Repo:    form.Repo,
+		Cluster:           form.Cluster,
+		Repo:              form.Repo,
+		DigitalOceanOAuth: form.DigitalOceanOAuth,
 	}
 
 	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
-	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
@@ -77,12 +83,15 @@ func GetAgentInClusterConfig(form *Form, l *logger.Logger) (*Agent, error) {
 	}
 
 	// 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
@@ -93,14 +102,16 @@ func GetAgentTesting(form *Form, storage *storage.Storage, l *logger.Logger) *Ag
 		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 (
 	"bufio"
+	"bytes"
 	"context"
 	"fmt"
 	"io"
@@ -11,10 +12,17 @@ import (
 	"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/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/gke"
 	"github.com/porter-dev/porter/internal/models"
 	"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/porter-dev/porter/internal/helm/grapher"
@@ -22,6 +30,7 @@ import (
 	batchv1 "k8s.io/api/batch/v1"
 	v1 "k8s.io/api/core/v1"
 	v1beta1 "k8s.io/api/extensions/v1beta1"
+	"k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/cli-runtime/pkg/genericclioptions"
 	"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.
 func (a *Agent) GetPodLogs(namespace string, name string, conn *websocket.Conn) error {
+	tails := int64(400)
+
 	// follow logs
-	tails := int64(30)
 	podLogOpts := v1.PodLogOptions{
 		Follow:    true,
 		TailLines: &tails,
@@ -150,6 +160,7 @@ func (a *Agent) GetPodLogs(namespace string, name string, conn *websocket.Conn)
 				return
 			default:
 			}
+
 			bytes, err := r.ReadBytes('\n')
 			if writeErr := conn.WriteMessage(websocket.TextMessage, bytes); writeErr != nil {
 				errorchan <- writeErr
@@ -241,13 +252,14 @@ func (a *Agent) ProvisionECR(
 	projectID uint,
 	awsConf *integrations.AWSIntegration,
 	ecrName string,
+	repo repository.Repository,
 	infra *models.Infra,
 	operation provisioner.ProvisionerOperation,
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
 	provImageTag string,
 ) (*batchv1.Job, error) {
-	id := infra.GetID()
+	id := infra.GetUniqueName()
 	prov := &provisioner.Conf{
 		ID:                  id,
 		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
@@ -256,6 +268,7 @@ func (a *Agent) ProvisionECR(
 		Redis:               redisConf,
 		Postgres:            pgConf,
 		ProvisionerImageTag: provImageTag,
+		LastApplied:         infra.LastApplied,
 		AWS: &aws.Conf{
 			AWSRegion:          awsConf.AWSRegion,
 			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
@@ -274,13 +287,14 @@ func (a *Agent) ProvisionEKS(
 	projectID uint,
 	awsConf *integrations.AWSIntegration,
 	eksName string,
+	repo repository.Repository,
 	infra *models.Infra,
 	operation provisioner.ProvisionerOperation,
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
 	provImageTag string,
 ) (*batchv1.Job, error) {
-	id := infra.GetID()
+	id := infra.GetUniqueName()
 	prov := &provisioner.Conf{
 		ID:                  id,
 		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
@@ -289,6 +303,7 @@ func (a *Agent) ProvisionEKS(
 		Redis:               redisConf,
 		Postgres:            pgConf,
 		ProvisionerImageTag: provImageTag,
+		LastApplied:         infra.LastApplied,
 		AWS: &aws.Conf{
 			AWSRegion:          awsConf.AWSRegion,
 			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
 func (a *Agent) ProvisionGCR(
 	projectID uint,
 	gcpConf *integrations.GCPIntegration,
+	repo repository.Repository,
 	infra *models.Infra,
 	operation provisioner.ProvisionerOperation,
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
 	provImageTag string,
 ) (*batchv1.Job, error) {
-	id := infra.GetID()
+	id := infra.GetUniqueName()
 	prov := &provisioner.Conf{
 		ID:                  id,
 		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
@@ -321,6 +337,7 @@ func (a *Agent) ProvisionGCR(
 		Redis:               redisConf,
 		Postgres:            pgConf,
 		ProvisionerImageTag: provImageTag,
+		LastApplied:         infra.LastApplied,
 		GCP: &gcp.Conf{
 			GCPRegion:    gcpConf.GCPRegion,
 			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
@@ -336,13 +353,14 @@ func (a *Agent) ProvisionGKE(
 	projectID uint,
 	gcpConf *integrations.GCPIntegration,
 	gkeName string,
+	repo repository.Repository,
 	infra *models.Infra,
 	operation provisioner.ProvisionerOperation,
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
 	provImageTag string,
 ) (*batchv1.Job, error) {
-	id := infra.GetID()
+	id := infra.GetUniqueName()
 	prov := &provisioner.Conf{
 		ID:                  id,
 		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
@@ -351,6 +369,7 @@ func (a *Agent) ProvisionGKE(
 		Redis:               redisConf,
 		Postgres:            pgConf,
 		ProvisionerImageTag: provImageTag,
+		LastApplied:         infra.LastApplied,
 		GCP: &gcp.Conf{
 			GCPRegion:    gcpConf.GCPRegion,
 			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
 func (a *Agent) ProvisionTest(
 	projectID uint,
+	infra *models.Infra,
+	repo repository.Repository,
 	operation provisioner.ProvisionerOperation,
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
 	provImageTag string,
 ) (*batchv1.Job, error) {
+	id := infra.GetUniqueName()
+
 	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,
 		Kind:                provisioner.Test,
 		Redis:               redisConf,
@@ -382,11 +505,13 @@ func (a *Agent) ProvisionTest(
 		ProvisionerImageTag: provImageTag,
 	}
 
-	return a.provision(prov)
+	return a.provision(prov, infra, repo)
 }
 
 func (a *Agent) provision(
 	prov *provisioner.Conf,
+	infra *models.Infra,
+	repo repository.Repository,
 ) (*batchv1.Job, error) {
 	prov.Namespace = "default"
 
@@ -396,9 +521,106 @@ func (a *Agent) provision(
 		return nil, err
 	}
 
-	return a.Clientset.BatchV1().Jobs(prov.Namespace).Create(
+	job, err = a.Clientset.BatchV1().Jobs(prov.Namespace).Create(
 		context.TODO(),
 		job,
 		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"
 
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/repository"
+	"golang.org/x/oauth2"
 	"k8s.io/apimachinery/pkg/api/meta"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/cli-runtime/pkg/genericclioptions"
@@ -88,6 +90,9 @@ func GetAgentTesting(objects ...runtime.Object) *Agent {
 type OutOfClusterConfig struct {
 	Cluster *models.Cluster
 	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
@@ -268,7 +273,11 @@ func (conf *OutOfClusterConfig) createRawConfigFromCluster() (*api.Config, error
 			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 {
 			return nil, err
@@ -291,6 +300,23 @@ func (conf *OutOfClusterConfig) createRawConfigFromCluster() (*api.Config, error
 			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
 		authInfoMap[authInfoName].Token = tok
 	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
 		for _, msg := range xstreams[0].Messages {
 			// 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" {
 				infra, err := repo.Infra.ReadInfra(infraID)
@@ -255,6 +255,59 @@ func GlobalStreamListener(
 
 					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 {
 						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/ecr"
 	"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/gke"
@@ -27,6 +31,8 @@ const (
 	EKS  InfraOption = "eks"
 	GCR  InfraOption = "gcr"
 	GKE  InfraOption = "gke"
+	DOCR InfraOption = "docr"
+	DOKS InfraOption = "doks"
 )
 
 // Conf is the config required to start a provisioner container
@@ -39,6 +45,7 @@ type Conf struct {
 	Postgres            *config.DBConf
 	Operation           ProvisionerOperation
 	ProvisionerImageTag string
+	LastApplied         []byte
 
 	// provider-specific configurations
 
@@ -50,6 +57,11 @@ type Conf struct {
 	// GKE
 	GCP *gcp.Conf
 	GKE *gke.Conf
+
+	// DO
+	DO   *do.Conf
+	DOCR *docr.Conf
+	DOKS *doks.Conf
 }
 
 type ProvisionerOperation string
@@ -74,11 +86,7 @@ func (conf *Conf) GetProvisionerJobTemplate() (*batchv1.Job, error) {
 
 	ttl := int32(3600)
 
-	backoffLimit := int32(5)
-
-	if operation == string(Apply) {
-		backoffLimit = int32(1)
-	}
+	backoffLimit := int32(1)
 
 	labels := map[string]string{
 		"app": "provisioner",
@@ -86,23 +94,200 @@ func (conf *Conf) GetProvisionerJobTemplate() (*batchv1.Job, error) {
 
 	args := make([]string, 0)
 
-	if conf.Kind == Test {
+	switch conf.Kind {
+	case Test:
 		args = []string{operation, "test", "hello"}
-	} else if conf.Kind == ECR {
+	case 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.ECR.AttachECREnv(env)
-	} else if conf.Kind == EKS {
+	case 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.EKS.AttachEKSEnv(env)
-	} else if conf.Kind == GCR {
+	case 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)
-	} else if conf.Kind == GKE {
+	case 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.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{

+ 2 - 0
internal/models/cluster.go

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

+ 21 - 7
internal/models/infra.go

@@ -25,10 +25,13 @@ type InfraKind string
 
 // The supported infra kinds
 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
@@ -53,6 +56,17 @@ type Infra struct {
 
 	// The GCP integration that was used to create the infra
 	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
@@ -80,12 +94,12 @@ func (i *Infra) Externalize() *InfraExternal {
 }
 
 // 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)
 }
 
-// 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, "-")
 
 	if len(strArr) < 3 {

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

@@ -2,6 +2,7 @@ package integrations
 
 import (
 	"context"
+	"encoding/json"
 
 	"golang.org/x/oauth2/google"
 	"gorm.io/gorm"
@@ -81,6 +82,7 @@ func (g *GCPIntegration) ToProjectIntegration(
 func (g *GCPIntegration) GetBearerToken(
 	getTokenCache GetTokenCacheFunc,
 	setTokenCache SetTokenCacheFunc,
+	scopes ...string,
 ) (string, error) {
 	cache, err := getTokenCache()
 
@@ -94,7 +96,7 @@ func (g *GCPIntegration) GetBearerToken(
 	creds, err := google.CredentialsFromJSON(
 		context.Background(),
 		g.GCPKeyData,
-		"https://www.googleapis.com/auth/cloud-platform",
+		scopes...,
 	)
 
 	if err != nil {
@@ -112,3 +114,40 @@ func (g *GCPIntegration) GetBearerToken(
 
 	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"
 	GCR      IntegrationService = "gcr"
 	ECR      IntegrationService = "ecr"
+	DOCR     IntegrationService = "docr"
 	Github   IntegrationService = "github"
 	Docker   IntegrationService = "docker"
 )

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

@@ -1,13 +1,16 @@
 package integrations
 
-import "gorm.io/gorm"
+import (
+	"gorm.io/gorm"
+)
 
 // OAuthIntegrationClient is the name of an OAuth mechanism client
 type OAuthIntegrationClient string
 
 // The supported oauth mechanism clients
 const (
-	OAuthGithub OAuthIntegrationClient = "github"
+	OAuthGithub       OAuthIntegrationClient = "github"
+	OAuthDigitalOcean OAuthIntegrationClient = "do"
 )
 
 // 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
 	HelmRepos []HelmRepo `json:"helm_repos"`
 
+	// invitations to the project
+	Invites []Invite `json:"invites"`
+
 	// provisioned aws infra
 	Infras []Infra `json:"infras"`
 

+ 3 - 0
internal/models/registry.go

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

+ 50 - 0
internal/oauth/config.go

@@ -1,9 +1,13 @@
 package oauth
 
 import (
+	"context"
 	"crypto/rand"
 	"encoding/base64"
+	"time"
 
+	"github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/repository"
 	"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 {
 	b := make([]byte, 16)
 	rand.Read(b)
@@ -35,3 +52,36 @@ func CreateRandomState() string {
 
 	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
 
 import (
+	"context"
+	"encoding/base64"
 	"encoding/json"
 	"fmt"
 	"net/http"
@@ -10,9 +12,15 @@ import (
 
 	"github.com/aws/aws-sdk-go/service/ecr"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/repository"
+	"golang.org/x/oauth2"
 
 	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
@@ -46,7 +54,10 @@ type Image struct {
 }
 
 // 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
 	if r.AWSIntegrationID != 0 {
 		return r.listECRRepositories(repo)
@@ -56,6 +67,10 @@ func (r *Registry) ListRepositories(repo repository.Repository) ([]*Repository,
 		return r.listGCRRepositories(repo)
 	}
 
+	if r.DOIntegrationID != 0 {
+		return r.listDOCRRepositories(repo, doAuth)
+	}
+
 	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
-	_, 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 {
 		return nil, err
@@ -187,6 +206,52 @@ func (r *Registry) listECRRepositories(repo repository.Repository) ([]*Repositor
 	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) {
 	return &ints.TokenCache{
 		Token:  r.TokenCache.Token,
@@ -216,6 +281,7 @@ func (r *Registry) setTokenCacheFunc(
 func (r *Registry) ListImages(
 	repoName string,
 	repo repository.Repository,
+	doAuth *oauth2.Config, // only required if using DOCR
 ) ([]*Image, error) {
 	// switch on the auth mechanism to get a token
 	if r.AWSIntegrationID != 0 {
@@ -226,6 +292,10 @@ func (r *Registry) ListImages(
 		return r.listGCRImages(repoName, repo)
 	}
 
+	if r.DOIntegrationID != 0 {
+		return r.listDOCRImages(repoName, repo, doAuth)
+	}
+
 	return nil, fmt.Errorf("error listing images")
 }
 
@@ -326,3 +396,206 @@ func (r *Registry) listGCRImages(repoName string, repo repository.Repository) ([
 
 	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
 }
 
+// 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
 // writing to the DB
 func (repo *OAuthIntegrationRepository) EncryptOAuthIntegrationData(

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

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

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

@@ -3,6 +3,7 @@ package gorm_test
 import (
 	"os"
 	"testing"
+	"time"
 
 	"github.com/porter-dev/porter/internal/adapter"
 	"github.com/porter-dev/porter/internal/config"
@@ -23,6 +24,7 @@ type tester struct {
 	initClusters []*models.Cluster
 	initHRs      []*models.HelmRepo
 	initInfras   []*models.Infra
+	initInvites  []*models.Invite
 	initCCs      []*models.ClusterCandidate
 	initKIs      []*ints.KubeIntegration
 	initBasics   []*ints.BasicIntegration
@@ -58,6 +60,7 @@ func setupTestEnv(tester *tester, t *testing.T) {
 		&models.ClusterCandidate{},
 		&models.ClusterResolver{},
 		&models.Infra{},
+		&models.Invite{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},
@@ -457,3 +460,28 @@ func initInfra(tester *tester, t *testing.T) {
 
 	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
 
 import (
+	"fmt"
+
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 	"gorm.io/gorm"
@@ -8,17 +10,24 @@ import (
 
 // InfraRepository uses gorm.DB for querying the database
 type InfraRepository struct {
-	db *gorm.DB
+	db  *gorm.DB
+	key *[32]byte
 }
 
 // NewInfraRepository returns a InfraRepository which uses
 // 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
 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{}
 
 	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
 	}
 
+	err = repo.DecryptInfraData(infra, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
 	return infra, nil
 }
 
@@ -46,6 +61,14 @@ func (repo *InfraRepository) ReadInfra(id uint) (*models.Infra, error) {
 		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
 }
 
@@ -60,6 +83,10 @@ func (repo *InfraRepository) ListInfrasByProjectID(
 		return nil, err
 	}
 
+	for _, infra := range infras {
+		repo.DecryptInfraData(infra, repo.key)
+	}
+
 	return infras, nil
 }
 
@@ -67,9 +94,59 @@ func (repo *InfraRepository) ListInfrasByProjectID(
 func (repo *InfraRepository) UpdateInfra(
 	ai *models.Infra,
 ) (*models.Infra, error) {
+	err := repo.EncryptInfraData(ai, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
 	if err := repo.db.Save(ai).Error; err != nil {
 		return nil, err
 	}
 
+	err = repo.DecryptInfraData(ai, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
 	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)
+	}
+}

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