소스 검색

Merge branch 'master' into local-dev

Stefan McShane 3 년 전
부모
커밋
9d243bae0b
54개의 변경된 파일2811개의 추가작업 그리고 2520개의 파일을 삭제
  1. 2 2
      .github/workflows/prerelease.yaml
  2. 3 5
      README.md
  3. 4 2
      api/server/handlers/api_contract/update.go
  4. 36 1
      api/server/handlers/cluster/delete.go
  5. 49 0
      api/server/handlers/cluster/rename.go
  6. 1 0
      api/server/handlers/project/create_test.go
  7. 1 1
      api/server/handlers/project_integration/create_aws.go
  8. 5 2
      api/server/handlers/user/create.go
  9. 49 0
      api/server/handlers/user/update_user_info.go
  10. 29 0
      api/server/router/cluster.go
  11. 25 0
      api/server/router/user.go
  12. 4 0
      api/types/cluster.go
  13. 14 2
      api/types/user.go
  14. 722 1292
      dashboard/package-lock.json
  15. 7 9
      dashboard/package.json
  16. BIN
      dashboard/src/assets/blog.png
  17. BIN
      dashboard/src/assets/community.png
  18. BIN
      dashboard/src/assets/docs.png
  19. 3 3
      dashboard/src/components/CredentialsForm.tsx
  20. 42 38
      dashboard/src/components/ProvisionerSettings.tsx
  21. 1 1
      dashboard/src/components/YamlEditor.tsx
  22. 151 0
      dashboard/src/components/porter/Button.tsx
  23. 31 0
      dashboard/src/components/porter/Container.tsx
  24. 103 0
      dashboard/src/components/porter/Input.tsx
  25. 37 0
      dashboard/src/components/porter/Link.tsx
  26. 21 5
      dashboard/src/components/porter/Spacer.tsx
  27. 49 4
      dashboard/src/main/Main.tsx
  28. 259 392
      dashboard/src/main/auth/Login.tsx
  29. 303 381
      dashboard/src/main/auth/Register.tsx
  30. 316 0
      dashboard/src/main/auth/SetInfo.tsx
  31. 180 279
      dashboard/src/main/auth/VerifyEmail.tsx
  32. 2 2
      dashboard/src/main/home/Home.tsx
  33. 7 0
      dashboard/src/main/home/ModalHandler.tsx
  34. 151 0
      dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettingsModal.tsx
  35. 94 8
      dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx
  36. 8 2
      dashboard/src/main/home/cluster-dashboard/dashboard/ProvisionerStatus.tsx
  37. 5 24
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupList.tsx
  38. 3 0
      dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx
  39. 1 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  40. 2 18
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx
  41. 0 1
      dashboard/src/main/home/dashboard/ClusterList.tsx
  42. 2 1
      dashboard/src/main/home/dashboard/ClusterSection.tsx
  43. 2 2
      dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx
  44. 10 8
      dashboard/src/main/home/modals/Modal.tsx
  45. 1 1
      dashboard/src/shared/ace-porter-theme.js
  46. 36 0
      dashboard/src/shared/api.tsx
  47. 1 1
      dashboard/src/shared/error_handling/logger.ts
  48. 1 1
      dashboard/src/shared/types.tsx
  49. 1 1
      go.mod
  50. 2 2
      go.sum
  51. 1 0
      go.work.sum
  52. 7 0
      internal/models/user.go
  53. 9 9
      services/preview_env_setup_job/go.mod
  54. 18 18
      services/preview_env_setup_job/go.sum

+ 2 - 2
.github/workflows/prerelease.yaml

@@ -114,7 +114,7 @@ jobs:
       - name: Set up Go
         uses: actions/setup-go@v3
         with:
-          go-version: 1.18
+          go-version: 1.19
       - name: Set up Node
         uses: actions/setup-node@v3
         with:
@@ -184,7 +184,7 @@ jobs:
       - name: Set up Go
         uses: actions/setup-go@v3
         with:
-          go-version: 1.18
+          go-version: 1.19
       - name: Write Dashboard Environment Variables
         run: |
           cat >./dashboard/.env <<EOL

+ 3 - 5
README.md

@@ -5,7 +5,7 @@
 
 **Porter is a Kubernetes-powered PaaS that runs in your own cloud provider.** Porter brings the Heroku experience to your own AWS/GCP account, while upgrading your infrastructure to Kubernetes. Get started on Porter without the overhead of DevOps and customize your infrastructure later when you need to.
 
-![Provisioning View](https://user-images.githubusercontent.com/22849518/104234811-fe2dcb00-5421-11eb-9ce3-c0ebefc37476.png)
+<img width="1440" alt="image" src="https://user-images.githubusercontent.com/93286801/227250589-1ebe0f79-c352-4eb4-adfd-7cb10957be3d.png">
 
 ## Community and Updates
 
@@ -21,7 +21,7 @@ A traditional PaaS like Heroku is great for minimizing unnecessary DevOps work b
 
 Porter brings the simplicity of a traditional PaaS to your own cloud provider while preserving the configurability of Kubernetes. Porter is built on top of a popular Kubernetes package manager `helm` and is compatible with standard Kubernetes management tools like `kubectl`, preparing your infra for mature DevOps work from day one.
 
-![image](https://user-images.githubusercontent.com/65516095/103713478-71e75800-4f8a-11eb-915f-adee9d4f5bf7.png)
+<img width="1440" alt="image" src="https://user-images.githubusercontent.com/93286801/227251932-13caf45f-6082-4d6d-85f5-812698e09dae.png">
 
 ## Features
 
@@ -48,7 +48,7 @@ For those who are familiar with Kubernetes and Helm:
 - In-depth view of releases, including revision histories and component graphs
 - Rollback/update of existing releases, including editing of raw `values.yaml`
 
-![Graph View](https://user-images.githubusercontent.com/22849518/101073320-43322800-356d-11eb-9b69-a68bd951992e.png)
+<img width="1426" alt="image" src="https://user-images.githubusercontent.com/93286801/227253754-8e8921e9-4609-4e92-9e3d-6732fb9cfe1c.png">
 
 ## Docs
 
@@ -65,5 +65,3 @@ Below are instructions for a quickstart. For full documentation, please visit ou
 ## Want to Help?
 
 We welcome all contributions. If you're interested in contributing, please read our [contributing guide](https://github.com/porter-dev/porter/blob/master/CONTRIBUTING.md) and [join our Discord community](https://discord.gg/GJynMR3KXK).
-
-![porter](https://user-images.githubusercontent.com/65516095/103712859-def9ee00-4f88-11eb-804c-4b775d697ec4.jpeg)

+ 4 - 2
api/server/handlers/api_contract/update.go

@@ -13,6 +13,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
 )
 
@@ -34,6 +35,7 @@ func NewAPIContractUpdateHandler(
 // For now, this handling cluster creation only, by inserting a row into the cluster table in order to create an ID for this cluster, as well as stores the raw request JSON for updating later
 func (c *APIContractUpdateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	ctx := r.Context()
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
 	var apiContract porterv1.Contract
 
@@ -44,8 +46,8 @@ func (c *APIContractUpdateHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	if c.Config().DisableCAPIProvisioner {
-		// return dummy data if capi provisioner disabled
+	if !project.CapiProvisionerEnabled && c.Config().DisableCAPIProvisioner {
+		// return dummy data if capi provisioner disabled in project settings, and as env var
 		// TODO: remove this stub when we can spin up all services locally, easily
 		clusterID := apiContract.Cluster.ClusterId
 		if apiContract.Cluster.ClusterId == 0 {

+ 36 - 1
api/server/handlers/cluster/delete.go

@@ -1,8 +1,11 @@
 package cluster
 
 import (
+	"fmt"
 	"net/http"
 
+	"github.com/bufbuild/connect-go"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -28,7 +31,39 @@ func NewClusterDeleteHandler(
 }
 
 func (c *ClusterDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	ctx := r.Context()
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	if cluster.ProvisionedBy == "CAPI" {
+		if !c.Config().DisableCAPIProvisioner {
+			revisions, err := c.Config().Repo.APIContractRevisioner().List(ctx, cluster.ProjectID, cluster.ID)
+			if err != nil {
+				e := fmt.Errorf("error listing revisions for cluster %d: %w", cluster.ID, err)
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(e))
+				return
+			}
+			var revisionID string
+			for _, rev := range revisions {
+				if rev.Condition == "SUCCESS" {
+					revisionID = rev.ID.String()
+					break
+				}
+			}
+			cl := connect.NewRequest(&porterv1.DeleteClusterRequest{
+				ContractRevision: &porterv1.ContractRevision{
+					ClusterId:  int32(cluster.ID),
+					ProjectId:  int32(cluster.ProjectID),
+					RevisionId: revisionID,
+				},
+			})
+			_, err = c.Config().ClusterControlPlaneClient.DeleteCluster(ctx, cl)
+			if err != nil {
+				e := fmt.Errorf("error deleting cluster %d: %w", cluster.ID, err)
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(e))
+				return
+			}
+		}
+	}
 
 	err := c.Repo().Cluster().DeleteCluster(cluster)
 

+ 49 - 0
api/server/handlers/cluster/rename.go

@@ -0,0 +1,49 @@
+package cluster
+
+import (
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"net/http"
+)
+
+type RenameClusterHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewRenameClusterHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *RenameClusterHandler {
+	return &RenameClusterHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *RenameClusterHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &types.UpdateClusterRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	if request.Name != "" && cluster.VanityName != request.Name {
+		cluster.VanityName = request.Name
+	}
+
+	cluster, err := c.Repo().Cluster().UpdateCluster(cluster)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, cluster.ToClusterType())
+}

+ 1 - 0
api/server/handlers/project/create_test.go

@@ -42,6 +42,7 @@ func TestCreateProjectSuccessful(t *testing.T) {
 				ProjectID: 1,
 			},
 		},
+		CapiProvisionerEnabled: true,
 	}
 
 	gotProject := &types.CreateProjectResponse{}

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

@@ -51,7 +51,7 @@ func (p *CreateAWSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		AWSIntegration: aws.ToAWSIntegrationType(),
 	}
 
-	if !p.Config().DisableCAPIProvisioner {
+	if project.CapiProvisionerEnabled && !p.Config().DisableCAPIProvisioner {
 		credReq := porterv1.CreateAssumeRoleChainRequest{
 			ProjectId:       int64(project.ID),
 			SourceArn:       "arn:aws:iam::108458755588:role/CAPIManagement", // hard coded as this is the final hop for a CAPI cluster

+ 5 - 2
api/server/handlers/user/create.go

@@ -40,8 +40,11 @@ func (u *UserCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	}
 
 	user := &models.User{
-		Email:    request.Email,
-		Password: request.Password,
+		Email:       request.Email,
+		Password:    request.Password,
+		FirstName:   request.FirstName,
+		LastName:    request.LastName,
+		CompanyName: request.CompanyName,
 	}
 
 	// check if user exists

+ 49 - 0
api/server/handlers/user/update_user_info.go

@@ -0,0 +1,49 @@
+package user
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type UpdateUserInfoHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewUpdateUserInfoHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UpdateUserInfoHandler {
+	return &UpdateUserInfoHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (v *UpdateUserInfoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+
+	request := &types.UpdateUserInfoRequest{}
+	if ok := v.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	if request.FirstName != "" && request.LastName != "" && request.CompanyName != "" {
+		user.FirstName = request.FirstName
+		user.LastName = request.LastName
+		user.CompanyName = request.CompanyName
+	}
+
+	user, err := v.Repo().User().UpdateUser(user)
+	if err != nil {
+		v.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	v.WriteResult(w, r, user.ToUserType())
+}

+ 29 - 0
api/server/router/cluster.go

@@ -558,6 +558,35 @@ func getClusterRoutes(
 			Router:   r,
 		})
 
+		// POST /api/projects/{project_id}/clusters/{cluster_id}/rename -> cluster.NewRenameClusterHandler
+		renameClusterEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbCreate,
+				Method: types.HTTPVerbPost,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: relPath + "/rename",
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		renameClusterHandler := cluster.NewRenameClusterHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &router.Route{
+			Endpoint: renameClusterEndpoint,
+			Handler:  renameClusterHandler,
+			Router:   r,
+		})
+
 		// DELETE /api/projects/{project_id}/clusters/{cluster_id}/deployments/{deployment_id} ->
 		// environment.NewDeleteDeploymentHandler
 		deleteDeploymentEndpoint := factory.NewAPIEndpoint(

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

@@ -121,6 +121,31 @@ func getUserRoutes(
 		Router:   r,
 	})
 
+	// POST /api/users/update/info -> user.UpdateUserInfoHandler
+	updateUserInfoEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/users/update/info",
+			},
+			Scopes: []types.PermissionScope{types.UserScope},
+		},
+	)
+
+	updateUserInfoHandler := user.NewUpdateUserInfoHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: updateUserInfoEndpoint,
+		Handler:  updateUserInfoHandler,
+		Router:   r,
+	})
+
 	// GET /api/users/current -> user.NewUserGetCurrentHandler
 	authCheckEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 4 - 0
api/types/cluster.go

@@ -308,6 +308,10 @@ type UpdateClusterRequest struct {
 	PreviewEnvsEnabled *bool `json:"preview_envs_enabled"`
 }
 
+type RenameClusterRequest struct {
+	Name string `json:"name"`
+}
+
 type ListClusterResponse []*Cluster
 
 type CreateClusterCandidateResponse []*ClusterCandidate

+ 14 - 2
api/types/user.go

@@ -4,11 +4,17 @@ type User struct {
 	ID            uint   `json:"id"`
 	Email         string `json:"email"`
 	EmailVerified bool   `json:"email_verified"`
+	FirstName     string `json:"first_name"`
+	LastName      string `json:"last_name"`
+	CompanyName   string `json:"company_name"`
 }
 
 type CreateUserRequest struct {
-	Email    string `json:"email" form:"required,max=255,email"`
-	Password string `json:"password" form:"required,max=255"`
+	Email       string `json:"email" form:"required,max=255,email"`
+	Password    string `json:"password" form:"required,max=255"`
+	FirstName   string `json:"first_name" form:"required,max=255"`
+	LastName    string `json:"last_name" form:"required,max=255"`
+	CompanyName string `json:"company_name" form:"required,max=255"`
 }
 
 type CreateUserResponse User
@@ -68,3 +74,9 @@ type WelcomeWebhookRequest struct {
 	Role      string `json:"role" schema:"role"`
 	Name      string `json:"name" schema:"name"`
 }
+
+type UpdateUserInfoRequest struct {
+	FirstName   string `json:"first_name" form:"required,max=255"`
+	LastName    string `json:"last_name" form:"required,max=255"`
+	CompanyName string `json:"company_name" form:"required,max=255"`
+}

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 722 - 1292
dashboard/package-lock.json


+ 7 - 9
dashboard/package.json

@@ -22,10 +22,9 @@
     "@visx/scale": "^1.4.0",
     "@visx/shape": "^1.4.0",
     "@visx/tooltip": "^1.3.0",
-    "ace-builds": "^1.4.12",
+    "ace-builds": "^1.16.0",
     "anser": "^2.0.1",
     "axios": "^0.21.2",
-    "brace": "^0.11.1",
     "chroma-js": "^2.4.2",
     "clipboard": "^2.0.8",
     "color": "^4.2.3",
@@ -44,12 +43,12 @@
     "lodash": "^4.17.21",
     "markdown-to-jsx": "^7.0.1",
     "qs": "^6.9.4",
-    "random-words": "^1.1.1",
-    "react": "^16.13.1",
-    "react-ace": "^9.1.3",
+    "random-word-slugs": "^0.1.6",
+    "react": "^18.0.0",
+    "react-ace": "^8.0.0",
     "react-color": "^2.19.3",
     "react-datepicker": "^4.8.0",
-    "react-dom": "^16.13.1",
+    "react-dom": "^18.0.0",
     "react-error-boundary": "^3.1.3",
     "react-hot-toast": "^2.4.0",
     "react-infinite-scroll-component": "^6.1.0",
@@ -95,11 +94,10 @@
     "@types/material-ui": "^0.21.8",
     "@types/node": "^12.12.62",
     "@types/qs": "^6.9.5",
-    "@types/random-words": "^1.1.0",
-    "@types/react": "^16.14.14",
+    "@types/react": "^18.0.0",
     "@types/react-color": "^3.0.6",
     "@types/react-datepicker": "^4.4.2",
-    "@types/react-dom": "^16.9.8",
+    "@types/react-dom": "^18.0.0",
     "@types/react-modal": "^3.10.6",
     "@types/react-router": "^5.1.8",
     "@types/react-router-dom": "^5.1.5",

BIN
dashboard/src/assets/blog.png


BIN
dashboard/src/assets/community.png


BIN
dashboard/src/assets/docs.png


+ 3 - 3
dashboard/src/components/CredentialsForm.tsx

@@ -37,7 +37,7 @@ const CredentialsForm: React.FC<Props> = ({
   const [isLoading, setIsLoading] = useState(true);
   const [awsAccessKeyID, setAWSAccessKeyID] = useState("");
   const [awsSecretAccessKey, setAWSSecretAccessKey] = useState("");
-  const [selectedCredentials, setSelectedCredentials] = useState<AWSCredential>(null);
+  const [selectedCredentials, setSelectedCredentials] = useState(null);
   const [showCreateForm, setShowCreateForm] = useState(false);
   const [createStatus, setCreateStatus] = useState("");
 
@@ -128,7 +128,7 @@ const CredentialsForm: React.FC<Props> = ({
           <Br height="34px" />
           <SaveButton
             disabled={!selectedCredentials && true}
-            onClick={() => proceed(selectedCredentials.id)}
+            onClick={() => proceed(selectedCredentials.aws_arn)}
             clearPosition
             text="Continue"
           />
@@ -340,4 +340,4 @@ const StyledForm = styled.div`
   border: 1px solid #494b4f;
   font-size: 13px;
   margin-bottom: 30px;
-`;
+`;

+ 42 - 38
dashboard/src/components/ProvisionerSettings.tsx

@@ -45,6 +45,11 @@ const machineTypeOptions = [
   { value: "t3.2xlarge", label: "t3.2xlarge" },
 ];
 
+const clusterVersionOptions = [
+  { value: "v1.24.0", label: "1.24.0" },
+  { value: "v1.25.0", label: "1.25.0" },
+];
+
 type Props = RouteComponentProps & {
   selectedClusterVersion?: Contract;
   credentialId: string;
@@ -53,6 +58,7 @@ type Props = RouteComponentProps & {
 
 const ProvisionerSettings: React.FC<Props> = props => {
   const {
+    user,
     currentProject,
     currentCluster,
     setCurrentCluster,
@@ -67,6 +73,7 @@ const ProvisionerSettings: React.FC<Props> = props => {
   const [minInstances, setMinInstances] = useState(1);
   const [maxInstances, setMaxInstances] = useState(10);
   const [cidrRange, setCidrRange] = useState("172.0.0.0/16");
+  const [clusterVersion, setClusterVersion] = useState("v1.24.0");
   const [isReadOnly, setIsReadOnly] = useState(false);
 
   const createCluster = async () => {
@@ -80,7 +87,7 @@ const ProvisionerSettings: React.FC<Props> = props => {
           case: "eksKind",
           value: new EKS({
             clusterName,
-            clusterVersion: "v1.24.0",
+            clusterVersion: clusterVersion || "v1.24.0",
             cidrRange: cidrRange || "172.0.0.0/16",
             region: awsRegion,
             nodeGroups: [
@@ -115,7 +122,6 @@ const ProvisionerSettings: React.FC<Props> = props => {
       data["cluster"]["clusterId"] = props.clusterId;
     }
 
-    console.log(0);
     try {
       const res = await api.createContract(
         "<token>",
@@ -123,12 +129,8 @@ const ProvisionerSettings: React.FC<Props> = props => {
         { project_id: currentProject.id }
       );
 
-      console.log("res is:", res);
-      console.log("cluster id is:", res.data.contract_revision?.cluster_id);
-
       // Only refresh and set clusters on initial create
       if (!props.clusterId) {
-        console.log(1);
         setShouldRefreshClusters(true);
         api.getClusters(
           "<token>",
@@ -136,12 +138,9 @@ const ProvisionerSettings: React.FC<Props> = props => {
           { id: currentProject.id },
         )
           .then(({ data }) => {
-            console.log(2);
             data.forEach((cluster: ClusterType) => {
-              console.log("cluster id:", cluster.id)
               if (cluster.id === res.data.contract_revision?.cluster_id) {
                 // setHasFinishedOnboarding(true);
-                console.log(3);
                 setCurrentCluster(cluster);
                 OFState.actions.goTo("clean_up");
                 pushFiltered(props, "/cluster-dashboard", ["project_id"], {
@@ -181,6 +180,7 @@ const ProvisionerSettings: React.FC<Props> = props => {
       setCreateStatus("");
       setClusterName(contract.cluster.eksKind.clusterName);
       setAwsRegion(contract.cluster.eksKind.region);
+      setClusterVersion(contract.cluster.eksKind.clusterVersion);
       setCidrRange(contract.cluster.eksKind.cidrRange);
     }
   }, [props.selectedClusterVersion]);
@@ -226,7 +226,7 @@ const ProvisionerSettings: React.FC<Props> = props => {
         <InputRow
           width="350px"
           isRequired
-          disabled={isReadOnly}
+          disabled={isReadOnly || true}
           type="string"
           value={clusterName}
           setValue={(x: string) => setClusterName(x)}
@@ -236,44 +236,48 @@ const ProvisionerSettings: React.FC<Props> = props => {
         <SelectRow
           options={regionOptions}
           width="350px"
-          disabled={isReadOnly}
+          disabled={isReadOnly || true}
           value={awsRegion}
           scrollBuffer={true}
           dropdownMaxHeight="240px"
           setActiveValue={setAwsRegion}
           label="📍 AWS region"
         />
-        <SelectRow
-          options={machineTypeOptions}
-          width="350px"
-          disabled={isReadOnly}
-          value={machineType}
-          scrollBuffer={true}
-          dropdownMaxHeight="240px"
-          setActiveValue={setMachineType}
-          label="⚙️ Machine type"
-        />
-
-        <Heading>
-          <ExpandHeader
-            onClick={() => setIsExpanded(!isExpanded)}
-            isExpanded={isExpanded}
-          >
-            <i className="material-icons">arrow_drop_down</i>
-            Advanced settings
-          </ExpandHeader>
-        </Heading>
+        {
+          user?.isPorterUser && (
+            <Heading>
+              <ExpandHeader
+                onClick={() => setIsExpanded(!isExpanded)}
+                isExpanded={isExpanded}
+              >
+                <i className="material-icons">arrow_drop_down</i>
+                Advanced settings
+              </ExpandHeader>
+            </Heading>
+          )
+        }
         {
           isExpanded && (
             <>
-              <InputRow
+              <SelectRow
+                options={clusterVersionOptions}
                 width="350px"
-                type="number"
                 disabled={isReadOnly}
-                value={minInstances}
-                setValue={(x: number) => setMinInstances(x)}
-                label="Minimum number of application EC2 instances"
-                placeholder="ex: 1"
+                value={clusterVersion}
+                scrollBuffer={true}
+                dropdownMaxHeight="240px"
+                setActiveValue={setClusterVersion}
+                label="Cluster version"
+              />
+              <SelectRow
+                options={machineTypeOptions}
+                width="350px"
+                disabled={isReadOnly}
+                value={machineType}
+                scrollBuffer={true}
+                dropdownMaxHeight="240px"
+                setActiveValue={setMachineType}
+                label="Machine type"
               />
               <InputRow
                 width="350px"
@@ -281,7 +285,7 @@ const ProvisionerSettings: React.FC<Props> = props => {
                 disabled={isReadOnly}
                 value={maxInstances}
                 setValue={(x: number) => setMaxInstances(x)}
-                label="Minimum number of application EC2 instances"
+                label="Maximum number of application EC2 instances"
                 placeholder="ex: 1"
               />
               <InputRow

+ 1 - 1
dashboard/src/components/YamlEditor.tsx

@@ -3,6 +3,7 @@ import styled from "styled-components";
 import AceEditor from "react-ace";
 
 import "shared/ace-porter-theme";
+import 'ace-builds/src-noconflict/ext-searchbox';
 import "ace-builds/src-noconflict/mode-yaml";
 
 type PropsType = {
@@ -49,7 +50,6 @@ class YamlEditor extends Component<PropsType, StateType> {
             onChange={this.props.onChange}
             name="codeEditor"
             readOnly={this.props.readOnly}
-            editorProps={{ $blockScrolling: true }}
             height={this.props.height}
             width="100%"
             style={{ borderRadius: "10px" }}

+ 151 - 0
dashboard/src/components/porter/Button.tsx

@@ -0,0 +1,151 @@
+import React, { useEffect, useState } from "react";
+import styled, { keyframes } from "styled-components";
+
+import loading from "assets/loading.gif";
+
+type Props = {
+  children: React.ReactNode;
+  onClick: () => void;
+  disabled?: boolean;
+  status?: string;
+  helperText?: string;
+  loadingText?: string;
+  successText?: string;
+  width?: string;
+  height?: string;
+};
+
+const Button: React.FC<Props> = ({
+  children,
+  onClick,
+  disabled,
+  status,
+  helperText,
+  loadingText,
+  successText,
+  width,
+  height,
+}) => {
+  const renderStatus = () => {
+    switch(status) {
+      case "success":
+        return (
+          <StatusWrapper success={true}>
+            <i className="material-icons">done</i>
+            {successText || "Successfully updated"}
+          </StatusWrapper>
+        );
+      case "loading":
+        return (
+          <StatusWrapper success={false}>
+            <Loading src={loading} />
+            {loadingText || "Updating . . ."}
+          </StatusWrapper>
+        );
+      case "":
+        return helperText && (
+          <StatusWrapper success={false}>{helperText}</StatusWrapper>
+        )   
+      default:
+        return (
+          <StatusWrapper success={false}>
+            <i className="material-icons">error_outline</i>
+            Could not update
+          </StatusWrapper>
+        );
+    }
+  };
+
+  return (
+    <Wrapper>
+      <StyledButton
+        disabled={disabled}
+        onClick={() => !disabled && onClick()}
+        width={width}
+        height={height}
+      >
+        <Text>{children}</Text>
+      </StyledButton>
+      {(helperText || status) && renderStatus()}
+    </Wrapper>
+  );
+};
+
+export default Button;
+
+const Loading = styled.img`
+  width: 15px;
+  height: 15px;
+  margin-right: 9px;
+  margin-bottom: 0px;
+`;
+
+const floatIn = keyframes`
+  0% {
+    opacity: 0;
+    transform: translateY(10px);
+  }
+  100% {
+    opacity: 1;
+    transform: translateY(0px);
+  }
+`;
+
+const StatusWrapper = styled.div<{
+  success?: boolean;
+}>`
+  display: flex;
+  line-height: 1.5;
+  align-items: center;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #ffffff55;
+  margin-left: 15px;
+  max-width: 500px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  animation: ${floatIn} 0.5s;
+  animation-fill-mode: forwards;
+  > i {
+    font-size: 18px;
+    margin-right: 10px;
+    float: left;
+    color: ${props => props.success ? "#4797ff" : "#fcba03"};
+  }
+`;
+
+const Wrapper = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Text = styled.div`
+  display: flex;
+  align-items: center;
+  height: 100%;
+`;
+
+const StyledButton = styled.button<{
+  disabled: boolean;
+  width: string;
+  height: string;
+}>`
+  height: ${props => props.height || "35px"};
+  width: ${props => props.width || "auto"};
+  font-size: 13px;
+  cursor: ${props => props.disabled ? "not-allowed" : "pointer"};
+  padding: 15px;
+  border: none;
+  outline: none;
+  font-weight: 500;
+  color: white;
+  background: ${props => props.disabled ? "#aaaabb" : "#5561C0"};
+  display: flex;
+  ailgn-items: center;
+  justify-content: center;
+  border-radius: 5px;
+
+  :hover {
+    filter: ${props => props.disabled ? "" : "brightness(120%)"};
+  }
+`;

+ 31 - 0
dashboard/src/components/porter/Container.tsx

@@ -0,0 +1,31 @@
+import React, { useEffect, useState } from "react";
+import styled from "styled-components";
+
+type Props = {
+  children: React.ReactNode;
+  row?: boolean;
+};
+
+const Container: React.FC<Props> = ({
+  children,
+  row,
+}) => {
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  return (
+    <StyledContainer
+      row={row}
+    >
+      {children}
+    </StyledContainer>
+  );
+};
+
+export default Container;
+
+const StyledContainer = styled.div<{
+  row: boolean;
+}>`
+  display: ${props => props.row ? "flex" : "block"};
+  align-items: center;
+`;

+ 103 - 0
dashboard/src/components/porter/Input.tsx

@@ -0,0 +1,103 @@
+import React, { useEffect, useState } from "react";
+import styled from "styled-components";
+
+type Props = {
+  placeholder: string;
+  width?: string;
+  value: string;
+  setValue: (value: string) => void;
+  label?: string;
+  height?: string;
+  type?: string;
+  error?: string;
+  children?: React.ReactNode;
+};
+
+const Input: React.FC<Props> = ({
+  placeholder,
+  width,
+  value,
+  setValue,
+  label,
+  height,
+  type,
+  error,
+  children,
+}) => {
+  return (
+    <Block width={width}>
+      {
+        label && (
+          <Label>{label}</Label>
+        )
+      }
+      <StyledInput
+        value={value}
+        onChange={e => setValue(e.target.value)}
+        placeholder={placeholder}
+        width={width}
+        height={height}
+        type={type || "text"}
+        hasError={(error && true) || (error === "")}
+      />
+      {
+        error && (
+          <Error>
+            <i className="material-icons">error</i>
+            {error}
+          </Error>
+        )
+      }
+      {children}
+    </Block>
+  );
+};
+
+export default Input;
+
+const Block = styled.div<{
+  width: string;
+}>`
+  display: block;
+  position: relative;
+  width: ${props => props.width || "200px"};
+`;
+
+const Label = styled.div`
+  font-size: 13px;
+  color: #aaaabb;
+  margin-bottom: 10px;
+`;
+
+const Error = styled.div`
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  color: #ff3b62;
+  margin-top: 10px;
+
+  > i {
+    font-size: 18px;
+    margin-right: 5px;
+  }
+`;
+
+
+const StyledInput = styled.input<{
+  width: string;
+  height: string;
+  hasError: boolean;
+}>`
+  height: ${props => props.height || "35px"};
+  padding: 5px 10px;
+  width: ${props => props.width || "200px"};
+  color: #ffffff;
+  font-size: 13px;
+  outline: none;
+  border-radius: 5px;
+  background: #26292e;
+  border: 1px solid ${props => props.hasError ? "#ff3b62" : "#494b4f"};
+  :hover {
+    border: 1px solid ${props => props.hasError ? "#ff3b62" : "#7a7b80"};
+  }
+`;

+ 37 - 0
dashboard/src/components/porter/Link.tsx

@@ -0,0 +1,37 @@
+import DynamicLink from "components/DynamicLink";
+import React, { useEffect, useState } from "react";
+import styled from "styled-components";
+
+type Props = {
+  to?: string;
+  onClick?: () => void;
+  children: React.ReactNode;
+};
+
+const Link: React.FC<Props> = ({
+  to,
+  onClick,
+  children,
+}) => {
+  return (
+    <>
+      {to ? (
+        <StyledLink to={to}>{children}</StyledLink>
+      ) : (
+        <Div onClick={onClick}>{children}</Div>
+      )}
+    </>
+  );
+};
+
+export default Link;
+
+const Div = styled.span`
+  color: #8590ff;
+  cursor: pointer;
+`;
+
+const StyledLink = styled(DynamicLink)`
+  color: #8590ff;
+  cursor: pointer;
+`;

+ 21 - 5
dashboard/src/components/porter/Spacer.tsx

@@ -4,28 +4,44 @@ import styled from "styled-components";
 type Props = {
   height?: string;
   y?: number;
+  x?: number;
   inline?: boolean;
 };
 
 const Spacer: React.FC<Props> = ({
   height,
   y,
+  x,
   inline,
 }) => {
   const getCalcHeight = () => {
-    return 25 * y;
+    if (y) {
+      return 25 * y + "px";
+    }
+    return null
+  };
+
+  const getCalcWidth = () => {
+    if (x) {
+      return 15 * x + "px";
+    }
+    return "15px";
   };
   
   return (
     <StyledSpacer
-      height={height || (getCalcHeight() + "px")}
+      height={height || getCalcHeight()}
+      width={inline && getCalcWidth()}
     />
   );
 };
 
 export default Spacer;
 
-const StyledSpacer = styled.div<{ height: string }>`
-  height: ${props => props.height};
-  width: ${props => props.height ? "100%" : ""};
+const StyledSpacer = styled.div<{ 
+  height: string;
+  width: string;
+}>`
+  height: ${props => props.height || "100%"};
+  width: ${props => props.height ? "100%" : props.width};
 `;

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

@@ -8,6 +8,7 @@ import ResetPasswordFinalize from "./auth/ResetPasswordFinalize";
 import Login from "./auth/Login";
 import Register from "./auth/Register";
 import VerifyEmail from "./auth/VerifyEmail";
+import SetInfo from "./auth/SetInfo";
 import CurrentError from "./CurrentError";
 import Home from "./home/Home";
 import Loading from "components/Loading";
@@ -19,8 +20,11 @@ type StateType = {
   loading: boolean;
   isLoggedIn: boolean;
   isEmailVerified: boolean;
+  hasInfo: boolean;
   initialized: boolean;
   local: boolean;
+  userId: number;
+  version: string;
 };
 
 export default class Main extends Component<PropsType, StateType> {
@@ -28,11 +32,24 @@ export default class Main extends Component<PropsType, StateType> {
     loading: true,
     isLoggedIn: false,
     isEmailVerified: false,
+    hasInfo: false,
     initialized: localStorage.getItem("init") === "true",
     local: false,
+    userId: null as number,
+    version: null as string,
   };
 
   componentDidMount() {
+
+    // Get capabilities to case on user info requirements
+    api.getMetadata("", {}, {})
+      .then((res) => {
+        this.setState({
+          version: res.data?.version,
+        })
+      })
+      .catch((err) => console.log(err));
+
     let { setUser, setCurrentError } = this.context;
     let urlParams = new URLSearchParams(window.location.search);
     let error = urlParams.get("error");
@@ -41,12 +58,14 @@ export default class Main extends Component<PropsType, StateType> {
       .checkAuth("", {}, {})
       .then((res) => {
         if (res && res?.data) {
-          setUser(res?.data?.id, res?.data?.email);
+          setUser(res.data.id, res.data.email);
           this.setState({
             isLoggedIn: true,
-            isEmailVerified: res?.data?.email_verified,
+            isEmailVerified: res.data.email_verified,
             initialized: true,
+            hasInfo: res.data.company_name && true,
             loading: false,
+            userId: res.data.id,
           });
         } else {
           this.setState({ isLoggedIn: false, loading: false });
@@ -79,7 +98,9 @@ export default class Main extends Component<PropsType, StateType> {
             isLoggedIn: true,
             isEmailVerified: res?.data?.email_verified,
             initialized: true,
+            hasInfo: res.data.company_name && true,
             loading: false,
+            userId: res.data.id,
           });
         } else {
           this.setState({ isLoggedIn: false, loading: false });
@@ -104,7 +125,7 @@ export default class Main extends Component<PropsType, StateType> {
   };
 
   renderMain = () => {
-    if (this.state.loading) {
+    if (this.state.loading || !this.state.version) {
       return <Loading />;
     }
 
@@ -119,13 +140,37 @@ export default class Main extends Component<PropsType, StateType> {
           <Route
             path="/"
             render={() => {
-              return <VerifyEmail handleLogout={this.handleLogOut} />;
+              return <VerifyEmail handleLogOut={this.handleLogOut} />;
             }}
           />
         </Switch>
       );
     }
 
+    // Handle case where new user signs up via OAuth and has not set name and company
+    if (
+      this.state.version === "production" &&
+      !this.state.hasInfo && 
+      this.state.userId > 9312 &&
+      this.state.isLoggedIn
+    ) {
+      return (
+        <Switch>
+          <Route
+            path="/"
+            render={() => {
+              return (
+                <SetInfo 
+                  handleLogOut={this.handleLogOut}
+                  authenticate={this.authenticate}
+                />
+              );
+            }}
+          />
+        </Switch>
+      )
+    }
+
     return (
       <Switch>
         <Route

+ 259 - 392
dashboard/src/main/auth/Login.tsx

@@ -1,90 +1,61 @@
-import React, { ChangeEvent, Component } from "react";
+import React, { useEffect, useState, useContext } from "react";
 import styled from "styled-components";
-import logo from "assets/logo.png";
+
 import github from "assets/github-icon.png";
+import logo from "assets/logo.png";
+import docs from "assets/docs.png";
+import blog from "assets/blog.png";
+import community from "assets/community.png";
 import GoogleIcon from "assets/GoogleIcon";
 
 import api from "shared/api";
 import { emailRegex } from "shared/regex";
 import { Context } from "shared/Context";
 
-type PropsType = {
-  authenticate: () => void;
-};
+import DynamicLink from "components/DynamicLink";
+import Heading from "components/form-components/Heading";
+import Button from "components/porter/Button";
+import Container from "components/porter/Container";
+import Input from "components/porter/Input";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import Link from "components/porter/Link";
 
-type StateType = {
-  email: string;
-  password: string;
-  emailError: boolean;
-  credentialError: boolean;
-  hasBasic: boolean;
-  hasGithub: boolean;
-  hasGoogle: boolean;
-  hasResetPassword: boolean;
+type Props = {
+  authenticate: () => void;
 };
 
-export default class Login extends Component<PropsType, StateType> {
-  state = {
-    email: "",
-    password: "",
-    emailError: false,
-    credentialError: false,
-    hasBasic: true,
-    hasGithub: true,
-    hasGoogle: false,
-    hasResetPassword: true,
-  };
-
-  handleKeyDown = (e: any) => {
-    e.key === "Enter" ? this.handleLogin() : null;
-  };
-
-  componentDidMount() {
-    let urlParams = new URLSearchParams(window.location.search);
-    let emailFromCLI = urlParams.get("email");
-    emailFromCLI
-      ? this.setState({ email: emailFromCLI })
-      : document.addEventListener("keydown", this.handleKeyDown);
-
-    // get capabilities to case on github
-    api
-      .getMetadata("", {}, {})
-      .then((res) => {
-        this.setState({
-          hasBasic: res.data?.basic_login,
-          hasGithub: res.data?.github_login,
-          hasGoogle: res.data?.google_login,
-          hasResetPassword: res.data?.email,
-        });
-      })
-      .catch((err) => console.log(err));
-  }
-
-  componentWillUnmount() {
-    document.removeEventListener("keydown", this.handleKeyDown);
-  }
-
-  handleLogin = (): void => {
-    let { email, password } = this.state;
-    let { authenticate } = this.props;
-    let { setUser } = this.context;
+const getWindowDimensions = () => {
+  const { innerWidth: width, innerHeight: height } = window;
+  return { width, height };
+}
 
-    // Check for valid input
+const Login: React.FC<Props> = ({
+  authenticate,
+}) => {
+  const { setUser, setCurrentError } = useContext(Context);
+  const [email, setEmail] = useState("");
+  const [password, setPassword] = useState("");
+  const [emailError, setEmailError] = useState(false);
+  const [credentialError, setCredentialError] = useState(false);
+  const [hasBasic, setHasBasic] = useState(true);
+  const [hasGithub, setHasGithub] = useState(true);
+  const [hasGoogle, setHasGoogle] = useState(false);
+  const [hasResetPassword, setHasResetPassword] = useState(true);
+  const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions());
+
+  const handleLogin = (): void => {
     if (!emailRegex.test(email)) {
-      this.setState({ emailError: true });
+      setEmailError(true);
+    } else if (password === "") {
+      setCredentialError(true);
     } else {
-      // Attempt user login
-      api
-        .logInUser(
-          "",
-          {
-            email: email,
-            password: password,
-          },
-          {}
-        )
+      api.logInUser(
+        "",
+        { email: email, password: password },
+        {}
+      )
         .then((res) => {
-          // TODO: case and set credential error
           if (res?.data?.redirect) {
             window.location.href = res.data.redirect;
           } else {
@@ -92,396 +63,292 @@ export default class Login extends Component<PropsType, StateType> {
             authenticate();
           }
         })
-        .catch((err) => this.context.setCurrentError(err.response.data.error));
+        .catch((err) => setCurrentError(err.response.data.error));
     }
   };
 
-  renderEmailError = () => {
-    let { emailError } = this.state;
-    if (emailError) {
-      return (
-        <ErrorHelper>
-          <div />
-          Please enter a valid email
-        </ErrorHelper>
-      );
-    }
+  const handleResize = () => {
+    setWindowDimensions(getWindowDimensions());
   };
 
-  renderCredentialError = () => {
-    let { credentialError } = this.state;
-    if (credentialError) {
-      return (
-        <ErrorHelper>
-          <div />
-          Incorrect email or password
-        </ErrorHelper>
-      );
-    }
+  const handleKeyDown = (e: any) => {
+    if (e.key === "Enter") {
+      handleLogin();
+    };
   };
 
-  githubRedirect = () => {
+  // Manually re-register event listener on email/password change
+  useEffect(() => {
+    document.removeEventListener("keydown", handleKeyDown);
+    document.addEventListener("keydown", handleKeyDown);
+    return () => {
+      document.removeEventListener("keydown", handleKeyDown);
+    };
+  }, [email, password]);
+
+  useEffect(() => {
+
+    // Get capabilities to case on login methods
+    api.getMetadata("", {}, {})
+      .then((res) => {
+        setHasBasic(res.data?.basic_login);
+        setHasGithub(res.data?.github_login);
+        setHasGoogle(res.data?.google_login);
+        setHasResetPassword(res.data?.email);
+      })
+      .catch((err) => console.log(err));
+
+    const urlParams = new URLSearchParams(window.location.search);
+    const emailFromCLI = urlParams.get("email");
+    emailFromCLI && setEmail(emailFromCLI);
+
+    window.addEventListener('resize', handleResize);
+    return () => {
+      window.removeEventListener('resize', handleResize);
+    };
+  }, []);
+
+  const githubRedirect = () => {
     let redirectUrl = `/api/oauth/login/github`;
     window.location.href = redirectUrl;
   };
 
-  googleRedirect = () => {
+  const googleRedirect = () => {
     let redirectUrl = `/api/oauth/login/google`;
     window.location.href = redirectUrl;
   };
 
-  renderGithubSection = () => {
-    if (this.state.hasGithub) {
-      return (
-        <OAuthButton onClick={this.githubRedirect}>
-          <IconWrapper>
-            <Icon src={github} />
-            Log in with GitHub
-          </IconWrapper>
-        </OAuthButton>
-      );
-    }
-  };
-
-  renderGoogleSection = () => {
-    if (this.state.hasGoogle) {
-      return (
-        <OAuthButton onClick={this.googleRedirect}>
-          <IconWrapper>
-            <StyledGoogleIcon />
-            Log in with Google
-          </IconWrapper>
-        </OAuthButton>
-      );
-    }
-  };
-
-  renderBasicSection = () => {
-    if (this.state.hasBasic) {
-      let { email, password, credentialError, emailError } = this.state;
-
-      return (
-        <div>
-          <InputWrapper>
+  return (
+    <StyledLogin>
+      {windowDimensions.width > windowDimensions.height && (
+        <Wrapper>
+          <Logo src={logo} />
+          <Spacer y={2} />
+          <Jumbotron>
+            <Shiny>Welcome back to Porter</Shiny>
+          </Jumbotron>
+          <Spacer y={2} />
+          <LinkRow to="https://docs.porter.run" target="_blank">
+            <img src={docs} /> Read the Porter docs
+          </LinkRow>
+          <Spacer y={0.5} />
+          <LinkRow to="https://blog.porter.run" target="_blank">
+            <img src={blog} /> See what's new with Porter
+          </LinkRow>
+          <Spacer y={0.5} />
+          <LinkRow to="https://discord.com/invite/34n7NN7FJ7" target="_blank">
+            <img src={community} /> Join the community
+          </LinkRow>
+        </Wrapper>
+      )}
+      <Wrapper>
+        {windowDimensions.width <= windowDimensions.height && (
+          <Flex>
+            <Logo src={logo} />
+            <Spacer y={2} />
+          </Flex>
+        )}
+        <Heading isAtTop>
+          Log in to your Porter account
+        </Heading>
+        <Spacer y={1} />
+        {(hasGithub || hasGoogle) && (
+          <>
+            <Container row>
+              {hasGithub && (
+                <OAuthButton onClick={githubRedirect}>
+                  <Icon src={github} />
+                  Log in with GitHub
+                </OAuthButton>
+              )}
+              {hasGithub && hasGoogle && (
+                <Spacer inline x={2} />
+              )}
+              {hasGoogle && (
+                <OAuthButton onClick={googleRedirect}>
+                  <StyledGoogleIcon />
+                  Log in with Google
+                </OAuthButton>
+              )}
+            </Container>
+            {hasBasic && (
+              <OrWrapper>
+                <Line />
+                <Or>or</Or>
+              </OrWrapper>
+            )}
+          </>
+        )}
+        {hasBasic && (
+          <>
             <Input
               type="email"
               placeholder="Email"
+              label="Email"
               value={email}
-              onChange={(e: ChangeEvent<HTMLInputElement>) =>
-                this.setState({
-                  email: e.target.value,
-                  emailError: false,
-                  credentialError: false,
-                })
-              }
-              valid={!credentialError && !emailError}
+              setValue={(x) => {
+                setEmail(x);
+                setEmailError(false);
+                setCredentialError(false);
+              }}
+              width="100%"
+              height="40px"
+              error={emailError && "Please enter a valid email"}
             />
-            {this.renderEmailError()}
-          </InputWrapper>
-          <InputWrapper>
+            <Spacer y={1} />
             <Input
               type="password"
               placeholder="Password"
+              label="Password"
               value={password}
-              onChange={(e: ChangeEvent<HTMLInputElement>) =>
-                this.setState({
-                  password: e.target.value,
-                  credentialError: false,
-                })
-              }
-              valid={!credentialError}
-            />
-            {this.renderCredentialError()}
-          </InputWrapper>
-          <Button onClick={this.handleLogin}>Continue</Button>
-        </div>
-      );
-    }
-  };
-
-  renderHelper() {
-    if (this.state.hasResetPassword) {
-      return (
-        <Helper>
-          <Link href="/register">Sign up</Link> |
-          <Link href="/password/reset">Forgot password?</Link>
-        </Helper>
-      );
-    }
-
-    return (
-      <Helper>
-        <Link href="/register">Sign up</Link>
-      </Helper>
-    );
-  }
-
-  render() {
-    return (
-      <StyledLogin>
-        <LoginPanel
-          hasBasic={this.state.hasBasic}
-          numOAuth={+this.state.hasGithub + +this.state.hasGoogle}
+              setValue={(x) => {
+                setPassword(x);
+                setCredentialError(false);
+              }}
+              width="100%"
+              height="40px"
+              error={credentialError && ""}
+            >
+              {hasResetPassword && (
+                <ForgotPassword>
+                  <Link to="/password/reset">Forgot your password?</Link>
+                </ForgotPassword>
+              )}
+            </Input>
+            <Spacer height="30px" />
+            <Button onClick={handleLogin} width="100%" height="40px">
+              Continue
+            </Button>
+          </>
+        )}
+        <Spacer y={1} />
+        <Text 
+          size={13}
+          color="helper"
         >
-          <OverflowWrapper>
-            <GradientBg />
-          </OverflowWrapper>
-          <FormWrapper>
-            <Logo src={logo} />
-            <Prompt>Log in to Porter</Prompt>
-            {this.renderGithubSection()}
-            {this.renderGoogleSection()}
-            {(this.state.hasGithub || this.state.hasGoogle) &&
-            this.state.hasBasic ? (
-              <OrWrapper>
-                <Line />
-                <Or>or</Or>
-              </OrWrapper>
-            ) : null}
-            <DarkMatter />
-            {this.renderBasicSection()}
-            {this.renderHelper()}
-          </FormWrapper>
-        </LoginPanel>
-        <Footer>
-          © 2021 Porter Technologies Inc. •
-          <Link
-            href="https://docs.getporter.dev/docs/terms-of-service"
-            target="_blank"
-          >
-            Terms & Privacy
-          </Link>
-        </Footer>
-      </StyledLogin>
-    );
-  }
-}
+          Don't have an account? <Link to="/register">Sign up</Link>
+        </Text>
+      </Wrapper>
+    </StyledLogin>
+  );
+};
 
-Login.contextType = Context;
+export default Login;
 
-const Footer = styled.div`
+const ForgotPassword = styled.div`
   position: absolute;
-  bottom: 0;
-  left: 0;
-  margin-bottom: 30px;
-  width: 100vw;
-  text-align: center;
-  color: #aaaabb;
+  right: 0;
+  top: 0;
   font-size: 13px;
-  padding-right: 8px;
-  font: Work Sans, sans-serif;
-`;
-
-const DarkMatter = styled.div`
-  margin-top: -10px;
 `;
 
-const Or = styled.div`
-  position: absolute;
-  width: 30px;
-  text-align: center;
-  background: #111114;
-  z-index: 999;
-  left: calc(50% - 15px);
-  margin-top: -1px;
-`;
-
-const OrWrapper = styled.div`
+const Flex = styled.div`
   display: flex;
   align-items: center;
-  color: #ffffff44;
-  font-size: 14px;
-  position: relative;
+  justify-content: center;
+  flex-direction: column;
 `;
 
-const IconWrapper = styled.div`
+const LinkRow = styled(DynamicLink)`
+  font-size: 14px;
   display: flex;
   align-items: center;
-  justify-content: center;
-  padding: 0 10px;
-  height: 100%;
-`;
+  width: 220px;
+  color: #aaaabb;
+  > i {
+    font-size: 18px;
+    margin-right: 10px;
+    float: left;
+    color: #4797ff;
+  }
 
-const Icon = styled.img`
-  height: 18px;
-  margin: 0 10px;
+  > img {
+    height: 18px;
+    margin-right: 10px;
+  }
+
+  :hover {
+    filter: brightness(2);
+  }
 `;
 
-const StyledGoogleIcon = styled(GoogleIcon)`
-  width: 38px;
-  height: 38px;
+const Shiny = styled.span`
+  background-image: linear-gradient(225deg, #fff, #7980ff);
+  -webkit-background-clip: text;
+  background-clip: text;
+  -webkit-text-fill-color: transparent;
 `;
 
-const OAuthButton = styled.button`
-  width: 200px;
-  height: 30px;
-  border: 0;
-  display: flex;
-  background: #ffffff;
-  align-items: center;
-  border-radius: 3px;
-  color: #000000;
-  cursor: pointer;
-  user-select: none;
+const Jumbotron = styled.div`
+  font-size: 32px;
   font-weight: 500;
-  font-size: 13px;
-  margin: 10px 0;
-  overflow: hidden;
-  :hover {
-    background: #ffffffdd;
-  }
+  line-height: 1.5;
 `;
 
-const Link = styled.a`
-  margin-left: 5px;
-  color: #819bfd;
+const Logo = styled.img`
+  height: 24px;
+  user-select: none;
 `;
 
-const Helper = styled.div`
-  position: absolute;
-  bottom: 30px;
-  width: 100%;
-  text-align: center;
-  font-size: 13px;
-  font-family: "Work Sans", sans-serif;
-  color: #ffffff44;
+const StyledGoogleIcon = styled(GoogleIcon)`
+  width: 38px;
+  height: 38px;
 `;
 
-const OverflowWrapper = styled.div`
-  position: absolute;
-  top: 0;
-  left: 0;
+const Line = styled.div`
+  height: 2px;
   width: 100%;
-  height: 100%;
-  overflow: hidden;
-  border-radius: 10px;
+  background: #ffffff22;
+  margin: 35px 0px 30px;
 `;
 
-const ErrorHelper = styled.div`
+const Or = styled.div`
   position: absolute;
-  right: -185px;
-  top: 8px;
-  height: 30px;
-  width: 170px;
-  user-select: none;
-  background: #272731;
-  font-family: "Work Sans", sans-serif;
-  font-size: 12px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: #ff3b62;
-  border-radius: 3px;
-
-  > div {
-    background: #272731;
-    height: 15px;
-    width: 15px;
-    position: absolute;
-    left: -3px;
-    top: 7px;
-    transform: rotate(45deg);
-    z-index: -1;
-  }
-`;
-
-const Line = styled.div`
-  min-height: 3px;
-  width: 100px;
+  width: 50px;
+  text-align: center;
+  background: #111114;
   z-index: 999;
-  background: #ffffff22;
-  margin: 30px 0px 30px;
+  left: calc(50% - 25px);
+  margin-top: -1px;
 `;
 
-const Button = styled.button`
-  width: 200px;
-  min-height: 30px;
+const OrWrapper = styled.div`
   display: flex;
-  justify-content: center;
   align-items: center;
-  font-family: "Work Sans", sans-serif;
-  cursor: pointer;
-  margin-top: 9px;
-  border-radius: 2px;
-  border: 0;
-  background: #819bfd;
-  color: white;
-  font-weight: 500;
+  color: #ffffff44;
   font-size: 14px;
-`;
-
-const InputWrapper = styled.div`
   position: relative;
 `;
 
-const Input = styled.input`
-  width: 200px;
-  font-family: "Work Sans", sans-serif;
-  margin: 8px 0px;
-  height: 30px;
-  padding: 8px;
-  background: #ffffff12;
-  color: #ffffff;
-  border: ${(props: { valid?: boolean }) =>
-    props.valid ? "0" : "1px solid #ff3b62"};
-  border-radius: 2px;
-  font-size: 14px;
-`;
-
-const Prompt = styled.div`
-  font-family: "Work Sans", sans-serif;
-  font-weight: 500;
-  font-size: 15px;
-  margin-bottom: 18px;
-`;
-
-const Logo = styled.img`
-  width: 110px;
-  margin-top: 55px;
-  margin-bottom: 40px;
-  user-select: none;
+const Icon = styled.img`
+  height: 18px;
+  margin: 14px;
 `;
 
-const FormWrapper = styled.div`
-  width: calc(100% - 8px);
-  height: calc(100% - 8px);
-  background: #111114;
-  z-index: 1;
-  border-radius: 10px;
+const OAuthButton = styled.div`
+  width: 100%;
+  height: 40px;
   display: flex;
-  flex-direction: column;
+  background: #ffffff;
   align-items: center;
-`;
-
-const GradientBg = styled.div`
-  background: linear-gradient(#8ce1ff, #a59eff, #fba8ff);
-  width: 200%;
-  height: 200%;
-  position: absolute;
-  top: -50%;
-  left: -50%;
-  animation: flip 6s infinite linear;
-  @keyframes flip {
-    from {
-      transform: rotate(0deg);
-    }
-    to {
-      transform: rotate(360deg);
-    }
+  border-radius: 5px;
+  color: #000000;
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 13px;
+  :hover {
+    background: #ffffffdd;
   }
 `;
 
-const LoginPanel = styled.div`
-  width: 330px;
-  height: ${(props: { numOAuth: number; hasBasic: boolean }) =>
-    280 + +props.hasBasic * 150 + props.numOAuth * 50}px;
-  background: white;
+const Wrapper = styled.div`
+  width: 500px;
   margin-top: -20px;
-  border-radius: 10px;
-  display: flex;
-  justify-content: center;
   position: relative;
-  align-items: center;
+  padding: 25px;
+  border-radius: 5px;
+  font-size: 13px;
 `;
 
 const StyledLogin = styled.div`
@@ -494,4 +361,4 @@ const StyledLogin = styled.div`
   top: 0;
   left: 0;
   background: #111114;
-`;
+`;

+ 303 - 381
dashboard/src/main/auth/Register.tsx

@@ -1,96 +1,89 @@
-import React, { ChangeEvent, Component } from "react";
+import React, { useEffect, useState, useContext } from "react";
 import styled from "styled-components";
-import logo from "assets/logo.png";
+
 import github from "assets/github-icon.png";
+import logo from "assets/logo.png";
 import GoogleIcon from "assets/GoogleIcon";
 
 import api from "shared/api";
 import { emailRegex } from "shared/regex";
 import { Context } from "shared/Context";
 
-type PropsType = {
-  authenticate: () => void;
-};
+import Heading from "components/form-components/Heading";
+import Button from "components/porter/Button";
+import Container from "components/porter/Container";
+import Input from "components/porter/Input";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import Link from "components/porter/Link";
 
-type StateType = {
-  email: string;
-  password: string;
-  confirmPassword: string;
-  emailError: boolean;
-  confirmPasswordError: boolean;
-  hasGithub: boolean;
-  hasGoogle: boolean;
-  hasBasic: boolean;
+type Props = {
+  authenticate: () => void;
 };
 
-export default class Register extends Component<PropsType, StateType> {
-  state = {
-    email: "",
-    password: "",
-    confirmPassword: "",
-    emailError: false,
-    confirmPasswordError: false,
-    hasBasic: true,
-    hasGithub: true,
-    hasGoogle: false,
-  };
-
-  handleKeyDown = (e: any) => {
-    e.key === "Enter" ? this.handleRegister() : null;
-  };
-
-  componentDidMount() {
-    document.addEventListener("keydown", this.handleKeyDown);
-
-    // get capabilities to case on github
-    api
-      .getMetadata("", {}, {})
-      .then((res) => {
-        this.setState({
-          hasGithub: res.data?.github_login,
-          hasGoogle: res.data?.google_login,
-          hasBasic: res.data?.basic_login,
-        });
-      })
-      .catch((err) => console.log(err));
-  }
-
-  componentWillUnmount() {
-    document.removeEventListener("keydown", this.handleKeyDown);
-  }
+const getWindowDimensions = () => {
+  const { innerWidth: width, innerHeight: height } = window;
+  return { width, height };
+}
 
-  githubRedirect = () => {
-    let redirectUrl = `/api/oauth/login/github`;
-    window.location.href = redirectUrl;
-  };
+const Register: React.FC<Props> = ({
+  authenticate,
+}) => {
+  const { setUser, setCurrentError } = useContext(Context);
+  const [firstName, setFirstName] = useState("");
+  const [firstNameError, setFirstNameError] = useState(false);
+  const [lastName, setLastName] = useState("");
+  const [lastNameError, setLastNameError] = useState(false);
+  const [companyName, setCompanyName] = useState("");
+  const [companyNameError, setCompanyNameError] = useState(false);
+  const [email, setEmail] = useState("");
+  const [emailError, setEmailError] = useState(false);
+  const [password, setPassword] = useState("");
+  const [passwordError, setPasswordError] = useState(false);
+  const [hasBasic, setHasBasic] = useState(true);
+  const [hasGithub, setHasGithub] = useState(true);
+  const [hasGoogle, setHasGoogle] = useState(false);
+  const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions());
+
+  const handleRegister = (): void => {
+    if (!emailRegex.test(email)) {
+      setEmailError(true);
+    }
 
-  googleRedirect = () => {
-    let redirectUrl = `/api/oauth/login/google`;
-    window.location.href = redirectUrl;
-  };
+    if (firstName === "") {
+      setFirstNameError(true);
+    }
 
-  handleRegister = (): void => {
-    let { email, password, confirmPassword } = this.state;
-    let { authenticate } = this.props;
-    let { setCurrentError, setUser } = this.context;
+    if (lastName === "") {
+      setLastNameError(true);
+    }
 
-    if (!emailRegex.test(email)) {
-      this.setState({ emailError: true });
+    if (password === "") {
+      setPasswordError(true);
     }
 
-    if (confirmPassword !== password) {
-      this.setState({ confirmPasswordError: true });
+    if (companyName === "") {
+      setCompanyNameError(true);
     }
 
     // Check for valid input
-    if (emailRegex.test(email) && confirmPassword === password) {
+    if (
+      emailRegex.test(email) && 
+      firstName !== "" &&
+      lastName !== "" &&
+      password !== "" &&
+      companyName !== ""
+    ) {
       // Attempt user registration
       api
         .registerUser(
           "",
-          {
+          { 
             email: email,
             password: password,
+            first_name: firstName,
+            last_name: lastName,
+            company_name: companyName,
           },
           {}
         )
@@ -106,183 +99,269 @@ export default class Register extends Component<PropsType, StateType> {
     }
   };
 
-  renderEmailError = () => {
-    let { emailError } = this.state;
-    if (emailError) {
-      return (
-        <ErrorHelper>
-          <div />
-          Please enter a valid email
-        </ErrorHelper>
-      );
-    }
+  const handleResize = () => {
+    setWindowDimensions(getWindowDimensions());
   };
 
-  renderConfirmPasswordError = () => {
-    let { confirmPasswordError } = this.state;
-    if (confirmPasswordError) {
-      return (
-        <ErrorHelper>
-          <div />
-          Passwords do not match
-        </ErrorHelper>
-      );
-    }
+  const handleKeyDown = (e: any) => {
+    if (e.key === "Enter") {
+      handleRegister();
+    };
   };
 
-  renderGithubSection = () => {
-    if (this.state.hasGithub) {
-      return (
-        <OAuthButton onClick={this.githubRedirect}>
-          <IconWrapper>
-            <Icon src={github} />
-            Sign up with GitHub
-          </IconWrapper>
-        </OAuthButton>
-      );
-    }
+  // Manually re-register event listener on email/password change
+  useEffect(() => {
+    document.removeEventListener("keydown", handleKeyDown);
+    document.addEventListener("keydown", handleKeyDown);
+    return () => {
+      document.removeEventListener("keydown", handleKeyDown);
+    };
+  }, [email, password, firstName, lastName]);
+
+  useEffect(() => {
+
+    // Get capabilities to case on login methods
+    api.getMetadata("", {}, {})
+      .then((res) => {
+        setHasBasic(res.data?.basic_login);
+        setHasGithub(res.data?.github_login);
+        setHasGoogle(res.data?.google_login);
+      })
+      .catch((err) => console.log(err));
+
+    window.addEventListener('resize', handleResize);
+    return () => window.removeEventListener('resize', handleResize);
+  }, []);
+
+  const githubRedirect = () => {
+    let redirectUrl = `/api/oauth/login/github`;
+    window.location.href = redirectUrl;
   };
 
-  renderGoogleSection = () => {
-    if (this.state.hasGoogle) {
-      return (
-        <OAuthButton onClick={this.googleRedirect}>
-          <IconWrapper>
-            <StyledGoogleIcon />
-            Sign up with Google
-          </IconWrapper>
-        </OAuthButton>
-      );
-    }
+  const googleRedirect = () => {
+    let redirectUrl = `/api/oauth/login/google`;
+    window.location.href = redirectUrl;
   };
 
-  renderBasicSection = () => {
-    let {
-      email,
-      password,
-      confirmPassword,
-      emailError,
-      confirmPasswordError,
-    } = this.state;
-
-    if (this.state.hasBasic) {
-      return (
-        <div>
-          <InputWrapper>
+  return (
+    <StyledRegister>
+      {windowDimensions.width > windowDimensions.height && (
+        <Wrapper>
+          <Logo src={logo} />
+          <Spacer y={2} />
+          <Jumbotron>
+            Deploy and scale <Shiny>effortlessly</Shiny> with Porter
+          </Jumbotron>
+          <Spacer y={2} />
+          <CheckRow>
+            <i className="material-icons">done</i> Generous free tier for small teams
+          </CheckRow>
+          <Spacer y={0.5} />
+          <CheckRow>
+            <i className="material-icons">done</i> Bring your own cloud (and cloud credits)
+          </CheckRow>
+          <Spacer y={0.5} />
+          <CheckRow>
+            <i className="material-icons">done</i> Fully automated setup and deployment
+          </CheckRow>
+        </Wrapper>
+      )}
+      <Wrapper>
+        {windowDimensions.width <= windowDimensions.height && (
+          <Flex>
+            <Logo src={logo} />
+            <Spacer y={2} />
+          </Flex>
+        )}
+        <Heading isAtTop>
+          Create your Porter account
+        </Heading>
+        <Spacer y={1} />
+        {(hasGithub || hasGoogle) && (
+          <>
+            <Container row>
+              {hasGithub && (
+                <OAuthButton onClick={githubRedirect}>
+                  <Icon src={github} />
+                  Sign up with GitHub
+                </OAuthButton>
+              )}
+              {hasGithub && hasGoogle && (
+                <Spacer inline x={2} />
+              )}
+              {hasGoogle && (
+                <OAuthButton onClick={googleRedirect}>
+                  <StyledGoogleIcon />
+                  Sign up with Google
+                </OAuthButton>
+              )}
+            </Container>
+            {hasBasic && (
+              <OrWrapper>
+                <Line />
+                <Or>or</Or>
+              </OrWrapper>
+            )}
+          </>
+        )}
+        {hasBasic && (
+          <>
+            <Container row>
+              <RowWrapper>
+                <Input
+                  placeholder="First name"
+                  label="First name"
+                  value={firstName}
+                  setValue={(x) => {
+                    setFirstName(x);
+                    setFirstNameError(false);
+                  }}
+                  width="100%"
+                  height="40px"
+                  error={(firstNameError && "First name cannot be blank")}
+                />
+                {!firstNameError && lastNameError && (
+                  <Spacer height="27px" />
+                )}
+              </RowWrapper>
+              <Spacer inline x={2} />
+              <RowWrapper>
+                <Input
+                  placeholder="Last name"
+                  label="Last name"
+                  value={lastName}
+                  setValue={(x) => {
+                    setLastName(x);
+                    setLastNameError(false);
+                  }}
+                  width="100%"
+                  height="40px"
+                  error={(lastNameError && "Last name cannot be blank")}
+                />
+                {!lastNameError && firstNameError && (
+                  <Spacer height="27px" />
+                )}
+              </RowWrapper>
+            </Container>
+            <Spacer y={1} />
+            <Input
+              placeholder="Company name"
+              label="Company name"
+              value={companyName}
+              setValue={(x) => {
+                setCompanyName(x);
+                setCompanyNameError(false);
+              }}
+              width="100%"
+              height="40px"
+              error={(companyNameError && "")}
+            />
+            <Spacer y={1} />
             <Input
               type="email"
               placeholder="Email"
+              label="Email"
               value={email}
-              onChange={(e: ChangeEvent<HTMLInputElement>) =>
-                this.setState({ email: e.target.value, emailError: false })
-              }
-              valid={!emailError}
+              setValue={(x) => {
+                setEmail(x);
+                setEmailError(false);
+              }}
+              width="100%"
+              height="40px"
+              error={(emailError && "Please enter a valid email")}
             />
-            {this.renderEmailError()}
-          </InputWrapper>
-          <Input
-            type="password"
-            placeholder="Password"
-            value={password}
-            onChange={(e: ChangeEvent<HTMLInputElement>) =>
-              this.setState({
-                password: e.target.value,
-                confirmPasswordError: false,
-              })
-            }
-            valid={true}
-          />
-          <InputWrapper>
+            <Spacer y={1} />
             <Input
+              placeholder="Password"
+              label="Password"
+              value={password}
+              setValue={setPassword}
+              width="100%"
+              height="40px"
               type="password"
-              placeholder="Confirm Password"
-              value={confirmPassword}
-              onChange={(e: ChangeEvent<HTMLInputElement>) =>
-                this.setState({
-                  confirmPassword: e.target.value,
-                  confirmPasswordError: false,
-                })
-              }
-              valid={!confirmPasswordError}
+              error={(passwordError && "")}
             />
-            {this.renderConfirmPasswordError()}
-          </InputWrapper>
-          <Button onClick={this.handleRegister}>Continue</Button>
-        </div>
-      );
-    }
-  };
-
-  render() {
-    return (
-      <StyledRegister>
-        <LoginPanel
-          hasBasic={this.state.hasBasic}
-          numOAuth={+this.state.hasGithub + +this.state.hasGoogle}
+            <Spacer height="30px" />
+            <Button onClick={handleRegister} width="100%" height="40px">
+              Continue
+            </Button>
+          </>
+        )}
+        <Spacer y={1} />
+        <Text 
+          size={13}
+          color="helper"
         >
-          <OverflowWrapper>
-            <GradientBg />
-          </OverflowWrapper>
-          <FormWrapper>
-            <Logo src={logo} />
-            <Prompt>Sign up for Porter</Prompt>
-            {this.renderGithubSection()}
-            {this.renderGoogleSection()}
-            {(this.state.hasGithub || this.state.hasGoogle) &&
-            this.state.hasBasic ? (
-              <OrWrapper>
-                <Line />
-                <Or>or</Or>
-              </OrWrapper>
-            ) : null}
-            <DarkMatter />
-            {this.renderBasicSection()}
-            <Helper>
-              Have an account?
-              <Link href="/login">Sign in</Link>
-            </Helper>
-          </FormWrapper>
-        </LoginPanel>
-        <Footer>
-          © 2021 Porter Technologies Inc. •
-          <Link
-            href="https://docs.getporter.dev/docs/terms-of-service"
-            target="_blank"
-          >
-            Terms & Privacy
-          </Link>
-        </Footer>
-      </StyledRegister>
-    );
-  }
-}
+          Already have an account? <Link to="/login">Log in</Link>
+        </Text>
+      </Wrapper>
+    </StyledRegister>
+  );
+};
 
-Register.contextType = Context;
+export default Register;
 
-const Footer = styled.div`
-  position: absolute;
-  bottom: 0;
-  left: 0;
-  margin-bottom: 30px;
-  width: 100vw;
-  text-align: center;
+const RowWrapper = styled.div`
+  width: 100%;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-direction: column;
+`;
+
+const CheckRow = styled.div`
+  font-size: 14px;
+  display: flex;
+  align-items: center;
   color: #aaaabb;
-  font-size: 13px;
-  padding-right: 8px;
-  font: Work Sans, sans-serif;
+  > i {
+    font-size: 18px;
+    margin-right: 10px;
+    float: left;
+    color: #4797ff;
+  }
 `;
 
-const DarkMatter = styled.div`
-  margin-top: -10px;
+const Shiny = styled.span`
+  background-image: linear-gradient(225deg, #fff, #7980ff);
+  -webkit-background-clip: text;
+  background-clip: text;
+  -webkit-text-fill-color: transparent;
+`;
+
+const Jumbotron = styled.div`
+  font-size: 32px;
+  font-weight: 500;
+  line-height: 1.5;
+`;
+
+const Logo = styled.img`
+  height: 24px;
+  user-select: none;
+`;
+
+const StyledGoogleIcon = styled(GoogleIcon)`
+  width: 38px;
+  height: 38px;
+`;
+
+const Line = styled.div`
+  height: 2px;
+  width: 100%;
+  background: #ffffff22;
+  margin: 35px 0px 30px;
 `;
 
 const Or = styled.div`
   position: absolute;
-  width: 30px;
+  width: 50px;
   text-align: center;
   background: #111114;
   z-index: 999;
-  left: calc(50% - 15px);
+  left: calc(50% - 25px);
   margin-top: -1px;
 `;
 
@@ -294,192 +373,35 @@ const OrWrapper = styled.div`
   position: relative;
 `;
 
-const IconWrapper = styled.div`
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  padding: 0 10px;
-  height: 100%;
-`;
-
 const Icon = styled.img`
   height: 18px;
   margin: 14px;
 `;
 
-const StyledGoogleIcon = styled(GoogleIcon)`
-  width: 38px;
-  height: 38px;
-`;
-
 const OAuthButton = styled.div`
-  width: 200px;
-  height: 30px;
+  width: 100%;
+  height: 40px;
   display: flex;
   background: #ffffff;
   align-items: center;
-  border-radius: 3px;
+  border-radius: 5px;
   color: #000000;
   cursor: pointer;
   user-select: none;
   font-weight: 500;
   font-size: 13px;
-  margin: 10px 0;
-  overflow: hidden;
   :hover {
     background: #ffffffdd;
   }
 `;
 
-const Link = styled.a`
-  margin-left: 5px;
-  color: #819bfd;
-`;
-
-const Helper = styled.div`
-  position: absolute;
-  bottom: 30px;
-  width: 100%;
-  text-align: center;
-  font-size: 13px;
-  font-family: "Work Sans", sans-serif;
-  color: #ffffff44;
-`;
-
-const OverflowWrapper = styled.div`
-  position: absolute;
-  top: 0;
-  left: 0;
-  width: 100%;
-  height: 100%;
-  overflow: hidden;
-  border-radius: 10px;
-`;
-
-const InputWrapper = styled.div`
-  position: relative;
-`;
-
-const ErrorHelper = styled.div`
-  position: absolute;
-  right: -185px;
-  top: 8px;
-  height: 30px;
-  width: 170px;
-  user-select: none;
-  background: #272731;
-  font-family: "Work Sans", sans-serif;
-  font-size: 12px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: #ff3b62;
-  border-radius: 3px;
-
-  > div {
-    background: #272731;
-    height: 15px;
-    width: 15px;
-    position: absolute;
-    left: -3px;
-    top: 7px;
-    transform: rotate(45deg);
-    z-index: -1;
-  }
-`;
-
-const Line = styled.div`
-  height: 3px;
-  width: 100px;
-  background: #ffffff22;
-  margin: 35px 0px 30px;
-`;
-
-const Button = styled.button`
-  width: 200px;
-  height: 30px;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  font-family: "Work Sans", sans-serif;
-  cursor: pointer;
-  margin-top: 9px;
-  border-radius: 2px;
-  border: 0;
-  background: #819bfd;
-  color: white;
-  font-weight: 500;
-  font-size: 14px;
-`;
-
-const Input = styled.input`
-  width: 200px;
-  font-family: "Work Sans", sans-serif;
-  margin: 8px 0px;
-  height: 30px;
-  padding: 8px;
-  background: #ffffff12;
-  color: #ffffff;
-  border: ${(props: { valid?: boolean }) =>
-    props.valid ? "0" : "1px solid #ff3b62"};
-  border-radius: 2px;
-  font-size: 14px;
-`;
-
-const Prompt = styled.div`
-  font-family: "Work Sans", sans-serif;
-  font-weight: 500;
-  font-size: 15px;
-  margin-bottom: 18px;
-`;
-
-const Logo = styled.img`
-  width: 110px;
-  margin-top: 45px;
-  margin-bottom: 30px;
-  user-select: none;
-`;
-
-const FormWrapper = styled.div`
-  width: calc(100% - 8px);
-  height: calc(100% - 8px);
-  background: #111114;
-  z-index: 1;
-  border-radius: 10px;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-`;
-
-const GradientBg = styled.div`
-  background: linear-gradient(#8ce1ff, #a59eff, #fba8ff);
-  width: 200%;
-  height: 200%;
-  position: absolute;
-  top: -50%;
-  left: -50%;
-  animation: flip 6s infinite linear;
-  @keyframes flip {
-    from {
-      transform: rotate(0deg);
-    }
-    to {
-      transform: rotate(360deg);
-    }
-  }
-`;
-
-const LoginPanel = styled.div`
-  width: 330px;
-  height: ${(props: { numOAuth: number; hasBasic: boolean }) =>
-    270 + +props.hasBasic * 180 + props.numOAuth * 50}px;
-  background: white;
+const Wrapper = styled.div`
+  width: 500px;
   margin-top: -20px;
-  border-radius: 10px;
-  display: flex;
-  justify-content: center;
   position: relative;
-  align-items: center;
+  padding: 25px;
+  border-radius: 5px;
+  font-size: 13px;
 `;
 
 const StyledRegister = styled.div`
@@ -492,4 +414,4 @@ const StyledRegister = styled.div`
   top: 0;
   left: 0;
   background: #111114;
-`;
+`;

+ 316 - 0
dashboard/src/main/auth/SetInfo.tsx

@@ -0,0 +1,316 @@
+import React, { useEffect, useState, useContext } from "react";
+import styled from "styled-components";
+
+import github from "assets/github-icon.png";
+import logo from "assets/logo.png";
+import GoogleIcon from "assets/GoogleIcon";
+
+import api from "shared/api";
+import { emailRegex } from "shared/regex";
+import { Context } from "shared/Context";
+
+import Heading from "components/form-components/Heading";
+import Button from "components/porter/Button";
+import Container from "components/porter/Container";
+import Input from "components/porter/Input";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import Link from "components/porter/Link";
+
+type Props = {
+  authenticate: () => void;
+  handleLogOut: () => void;
+};
+
+const getWindowDimensions = () => {
+  const { innerWidth: width, innerHeight: height } = window;
+  return { width, height };
+}
+
+const SetInfo: React.FC<Props> = ({
+  authenticate,
+  handleLogOut,
+}) => {
+  const { user, setCurrentError } = useContext(Context);
+  const [firstName, setFirstName] = useState("");
+  const [firstNameError, setFirstNameError] = useState(false);
+  const [lastName, setLastName] = useState("");
+  const [lastNameError, setLastNameError] = useState(false);
+  const [companyName, setCompanyName] = useState("");
+  const [companyNameError, setCompanyNameError] = useState(false);
+  const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions());
+
+  const handleResize = () => {
+    setWindowDimensions(getWindowDimensions());
+  };
+
+  const finishAccountSetup = async () => {
+    if (firstName === "") {
+      setFirstNameError(true);
+    }
+
+    if (lastName === "") {
+      setLastNameError(true);
+    }
+
+    if (companyName === "") {
+      setCompanyNameError(true);
+    }
+
+    if (
+      firstName !== "" &&
+      lastName !== "" &&
+      companyName !== ""
+    ) {
+      api.updateUserInfo(
+        "",
+        { 
+          first_name: firstName,
+          last_name: lastName,
+          company_name: companyName,
+        },
+        { id: user.id }
+      )
+        .then((res: any) => {
+          authenticate();
+        })
+        .catch((err) => setCurrentError(err));
+    }
+  };
+
+  const handleKeyDown = (e: any) => {
+    if (e.key === "Enter") {
+      finishAccountSetup();
+    };
+  };
+
+  // Manually re-register event listener on email/password change
+  useEffect(() => {
+    document.removeEventListener("keydown", handleKeyDown);
+    document.addEventListener("keydown", handleKeyDown);
+    return () => {
+      document.removeEventListener("keydown", handleKeyDown);
+    };
+  }, [firstName, lastName, companyName]);
+
+  useEffect(() => {
+    window.addEventListener('resize', handleResize);
+    return () => window.removeEventListener('resize', handleResize);
+  }, []);
+
+  return (
+    <StyledRegister>
+      {windowDimensions.width > windowDimensions.height && (
+        <Wrapper>
+          <Logo src={logo} />
+          <Spacer y={2} />
+          <Jumbotron>
+            Deploy and scale <Shiny>effortlessly</Shiny> with Porter
+          </Jumbotron>
+          <Spacer y={2} />
+          <CheckRow>
+            <i className="material-icons">done</i> Generous free tier for small teams
+          </CheckRow>
+          <Spacer y={0.5} />
+          <CheckRow>
+            <i className="material-icons">done</i> Bring your own cloud (and cloud credits)
+          </CheckRow>
+          <Spacer y={0.5} />
+          <CheckRow>
+            <i className="material-icons">done</i> Fully automated setup and deployment
+          </CheckRow>
+        </Wrapper>
+      )}
+      <Wrapper>
+        {windowDimensions.width <= windowDimensions.height && (
+          <Flex>
+            <Logo src={logo} />
+            <Spacer y={2} />
+          </Flex>
+        )}
+        <Heading isAtTop>
+          Finish setting up your account
+        </Heading>
+        <Spacer y={1} />
+        <Container row>
+          <RowWrapper>
+            <Input
+              placeholder="First name"
+              label="First name"
+              value={firstName}
+              setValue={(x) => {
+                setFirstName(x);
+                setFirstNameError(false);
+              }}
+              width="100%"
+              height="40px"
+              error={(firstNameError && "First name cannot be blank")}
+            />
+            {!firstNameError && lastNameError && (
+              <Spacer height="27px" />
+            )}
+          </RowWrapper>
+          <Spacer inline x={2} />
+          <RowWrapper>
+            <Input
+              placeholder="Last name"
+              label="Last name"
+              value={lastName}
+              setValue={(x) => {
+                setLastName(x);
+                setLastNameError(false);
+              }}
+              width="100%"
+              height="40px"
+              error={(lastNameError && "Last name cannot be blank")}
+            />
+            {!lastNameError && firstNameError && (
+              <Spacer height="27px" />
+            )}
+          </RowWrapper>
+        </Container>
+        <Spacer y={1} />
+        <Input
+          placeholder="Company name"
+          label="Company name"
+          value={companyName}
+          setValue={(x) => {
+            setCompanyName(x);
+            setCompanyNameError(false);
+          }}
+          width="100%"
+          height="40px"
+          error={(companyNameError && "")}
+        />
+        <Spacer height="30px" />
+        <Button onClick={finishAccountSetup} width="100%" height="40px">
+          Continue
+        </Button>
+        <Spacer y={1} />
+        <Text 
+          size={13}
+          color="helper"
+        >
+          Want to use a different login method? <Link onClick={handleLogOut}>Log out</Link>
+        </Text>
+      </Wrapper>
+    </StyledRegister>
+  );
+};
+
+export default SetInfo;
+
+const RowWrapper = styled.div`
+  width: 100%;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-direction: column;
+`;
+
+const CheckRow = styled.div`
+  font-size: 14px;
+  display: flex;
+  align-items: center;
+  color: #aaaabb;
+  > i {
+    font-size: 18px;
+    margin-right: 10px;
+    float: left;
+    color: #4797ff;
+  }
+`;
+
+const Shiny = styled.span`
+  background-image: linear-gradient(225deg, #fff, #7980ff);
+  -webkit-background-clip: text;
+  background-clip: text;
+  -webkit-text-fill-color: transparent;
+`;
+
+const Jumbotron = styled.div`
+  font-size: 32px;
+  font-weight: 500;
+  line-height: 1.5;
+`;
+
+const Logo = styled.img`
+  height: 24px;
+  user-select: none;
+`;
+
+const StyledGoogleIcon = styled(GoogleIcon)`
+  width: 38px;
+  height: 38px;
+`;
+
+const Line = styled.div`
+  height: 2px;
+  width: 100%;
+  background: #ffffff22;
+  margin: 35px 0px 30px;
+`;
+
+const Or = styled.div`
+  position: absolute;
+  width: 50px;
+  text-align: center;
+  background: #111114;
+  z-index: 999;
+  left: calc(50% - 25px);
+  margin-top: -1px;
+`;
+
+const OrWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  color: #ffffff44;
+  font-size: 14px;
+  position: relative;
+`;
+
+const Icon = styled.img`
+  height: 18px;
+  margin: 14px;
+`;
+
+const OAuthButton = styled.div`
+  width: 100%;
+  height: 40px;
+  display: flex;
+  background: #ffffff;
+  align-items: center;
+  border-radius: 5px;
+  color: #000000;
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 13px;
+  :hover {
+    background: #ffffffdd;
+  }
+`;
+
+const Wrapper = styled.div`
+  width: 500px;
+  margin-top: -20px;
+  position: relative;
+  padding: 25px;
+  border-radius: 5px;
+  font-size: 13px;
+`;
+
+const StyledRegister = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100vw;
+  height: 100vh;
+  position: fixed;
+  top: 0;
+  left: 0;
+  background: #111114;
+`;

+ 180 - 279
dashboard/src/main/auth/VerifyEmail.tsx

@@ -1,148 +1,209 @@
-import React, { Component } from "react";
+import React, { useEffect, useState, useContext } from "react";
 import styled from "styled-components";
+
+import github from "assets/github-icon.png";
 import logo from "assets/logo.png";
+import GoogleIcon from "assets/GoogleIcon";
 
 import api from "shared/api";
 import { Context } from "shared/Context";
 
-type PropsType = {
-  handleLogout: () => void;
-};
+import Heading from "components/form-components/Heading";
+import Button from "components/porter/Button";
+import Container from "components/porter/Container";
+import Input from "components/porter/Input";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import Link from "components/porter/Link";
 
-type StateType = {
-  submitted: boolean;
+type Props = {
+  handleLogOut: () => void;
 };
 
-export default class VerifyEmail extends Component<PropsType, StateType> {
-  state = {
-    submitted: false,
+const getWindowDimensions = () => {
+  const { innerWidth: width, innerHeight: height } = window;
+  return { width, height };
+}
+
+const Register: React.FC<Props> = ({
+  handleLogOut,
+}) => {
+  const { user, setCurrentError } = useContext(Context);
+  const [submitted, setSubmitted] = useState(false);
+  const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions());
+
+  const handleResize = () => {
+    setWindowDimensions(getWindowDimensions());
   };
 
-  handleSendEmail = (): void => {
-    api
-      .createEmailVerification("", {}, {})
+  useEffect(() => {
+    window.addEventListener('resize', handleResize);
+    return () => window.removeEventListener('resize', handleResize);
+  }, []);
+
+  const handleSendEmail = (): void => {
+    api.createEmailVerification("", {}, {})
       .then((res) => {
-        this.setState({ submitted: true });
+        setSubmitted(true);
       })
-      .catch((err) => this.context.setCurrentError(err.response.data.error));
+      .catch((err) => setCurrentError(err.response.data.error));
   };
 
-  render() {
-    let { submitted } = this.state;
-
-    let formSection = (
-      <div>
-        <InputWrapper>
-          <StatusText>A verification email should have been sent to</StatusText>
-          <Email>{this.context.user?.email}</Email>
-        </InputWrapper>
-        <StatusText>Didn't get it?</StatusText>
-        <Button onClick={this.handleSendEmail}>
-          Resend Verification Email
-        </Button>
-      </div>
-    );
-
-    if (submitted) {
-      formSection = (
-        <>
-          <Buffer />
-          <StatusText lessPadding={true}>
-            A verification email was sent to{" "}
-            <White>{this.context.user?.email}</White>
-          </StatusText>
-          <StatusText lessPadding={true}>
-            Check your inbox for a verification email. Don't forget to check
-            your spam folder
-          </StatusText>
-          <StatusText lessPadding={true}>
-            Need help?
-            <Link href="mailto:contact@getporter.dev">Contact us</Link>
-          </StatusText>
-        </>
-      );
-    }
-
-    return (
-      <StyledLogin>
-        <LoginPanel>
-          <OverflowWrapper>
-            <GradientBg />
-          </OverflowWrapper>
-          <FormWrapper>
+  return (
+    <StyledRegister>
+      {windowDimensions.width > windowDimensions.height && (
+        <Wrapper>
+          <Logo src={logo} />
+          <Spacer y={2} />
+          <Jumbotron>
+            Deploy and scale <Shiny>effortlessly</Shiny> with Porter
+          </Jumbotron>
+          <Spacer y={2} />
+          <CheckRow>
+            <i className="material-icons">done</i> Generous free tier for small teams
+          </CheckRow>
+          <Spacer y={0.5} />
+          <CheckRow>
+            <i className="material-icons">done</i> Bring your own cloud (and cloud credits)
+          </CheckRow>
+          <Spacer y={0.5} />
+          <CheckRow>
+            <i className="material-icons">done</i> Fully automated setup and deployment
+          </CheckRow>
+        </Wrapper>
+      )}
+      <Wrapper>
+        {windowDimensions.width <= windowDimensions.height && (
+          <Flex>
             <Logo src={logo} />
-            <Prompt>Verify Your Email</Prompt>
-            <DarkMatter />
-            {formSection}
-            <Helper>
-              Want to use a different email?
-              <Link onClick={this.props.handleLogout}>Log out</Link>
-            </Helper>
-          </FormWrapper>
-        </LoginPanel>
-
-        <Footer>
-          © 2021 Porter Technologies Inc. •
-          <Link
-            href="https://docs.getporter.dev/docs/terms-of-service"
-            target="_blank"
-          >
-            Terms & Privacy
-          </Link>
-        </Footer>
-      </StyledLogin>
-    );
-  }
-}
+            <Spacer y={2} />
+          </Flex>
+        )}
+        <Heading isAtTop>
+          Verify your email
+        </Heading>
+        <Spacer y={1} />
+        {submitted ? (
+          <>
+            <Text color="helper" size={13}>
+              A new verification email was sent to:
+            </Text>
+            <Spacer y={1} />
+            <Email>{user?.email}</Email>
+            <Spacer y={1} />
+            <Text color="helper" size={13}>
+              Don't forget to check your spam folder.
+            </Text>
+            <Spacer y={1} />
+            <Text color="helper" size={13}>
+              If you still need help, please contact support@porter.run.
+            </Text>
+          </>
+        ) : (
+          <>
+            <Text color="helper" size={13}>
+              We've sent a verification link to the following email address:
+            </Text>
+            <Spacer y={1} />
+            <Email>{user?.email}</Email>
+            <Spacer y={1} />
+            <Text color="helper" size={13}>
+              Please click the link in your inbox to verify your email.
+            </Text>
+            <Spacer y={1} />
+            <Text color="helper" size={13}>
+              Didn't receive anything?
+            </Text>
+            <Spacer height="30px" />
+            <Button onClick={handleSendEmail} width="100%" height="40px">
+              Resend verification email
+            </Button>
+          </>
+        )}
+        <Spacer y={1} />
+        <Text 
+          size={13}
+          color="helper"
+        >
+          Want to use a different email? <Link onClick={handleLogOut}>Log out</Link>
+        </Text>
+      </Wrapper>
+    </StyledRegister>
+  );
+};
 
-VerifyEmail.contextType = Context;
+export default Register;
 
-const Buffer = styled.div`
+const Email = styled.div`
   width: 100%;
-  height: 20px;
+  height: 40px;
+  border-radius: 5px;
+  background: #26292e;
+  border: 1px solid #494b4f;
+  font-size: 14px;
+  display: flex;
+  align-items: center;
+  padding: 15px;
+  color: #aaaabb;
 `;
 
-const White = styled.div`
-  color: white;
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-direction: column;
 `;
 
-const Email = styled.div`
-  background: #ffffff11;
-  border: 1px solid #ffffff44;
-  border-radius: 3px;
+const CheckRow = styled.div`
   font-size: 14px;
-  color: #aaaabb;
-  height: 30px;
-  margin: 0 60px;
   display: flex;
   align-items: center;
-  justify-content: center;
+  color: #aaaabb;
+  > i {
+    font-size: 18px;
+    margin-right: 10px;
+    float: left;
+    color: #4797ff;
+  }
 `;
 
-const Footer = styled.div`
-  position: absolute;
-  bottom: 0;
-  left: 0;
-  margin-bottom: 30px;
-  width: 100vw;
-  text-align: center;
-  color: #aaaabb;
-  font-size: 13px;
-  padding-right: 8px;
-  font: Work Sans, sans-serif;
+const Shiny = styled.span`
+  background-image: linear-gradient(225deg, #fff, #7980ff);
+  -webkit-background-clip: text;
+  background-clip: text;
+  -webkit-text-fill-color: transparent;
 `;
 
-const DarkMatter = styled.div`
-  margin-top: -20px;
+const Jumbotron = styled.div`
+  font-size: 32px;
+  font-weight: 500;
+  line-height: 1.5;
+`;
+
+const Logo = styled.img`
+  height: 24px;
+  user-select: none;
+`;
+
+const StyledGoogleIcon = styled(GoogleIcon)`
+  width: 38px;
+  height: 38px;
+`;
+
+const Line = styled.div`
+  height: 2px;
+  width: 100%;
+  background: #ffffff22;
+  margin: 35px 0px 30px;
 `;
 
 const Or = styled.div`
   position: absolute;
-  width: 30px;
+  width: 50px;
   text-align: center;
   background: #111114;
   z-index: 999;
-  left: calc(50% - 15px);
+  left: calc(50% - 25px);
   margin-top: -1px;
 `;
 
@@ -154,26 +215,18 @@ const OrWrapper = styled.div`
   position: relative;
 `;
 
-const IconWrapper = styled.div`
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  padding: 0 10px;
-  height: 100%;
-`;
-
 const Icon = styled.img`
   height: 18px;
-  margin-right: 20px;
+  margin: 14px;
 `;
 
 const OAuthButton = styled.div`
-  width: 200px;
-  height: 30px;
+  width: 100%;
+  height: 40px;
   display: flex;
   background: #ffffff;
   align-items: center;
-  border-radius: 3px;
+  border-radius: 5px;
   color: #000000;
   cursor: pointer;
   user-select: none;
@@ -184,168 +237,16 @@ const OAuthButton = styled.div`
   }
 `;
 
-const Link = styled.a`
-  margin-left: 5px;
-  color: #819bfd;
-  cursor: pointer;
-`;
-
-const Helper = styled.div`
-  position: absolute;
-  bottom: 30px;
-  width: 100%;
-  text-align: center;
-  font-size: 13px;
-  font-family: "Work Sans", sans-serif;
-  color: #ffffff44;
-`;
-
-const OverflowWrapper = styled.div`
-  position: absolute;
-  top: 0;
-  left: 0;
-  width: 100%;
-  height: 100%;
-  overflow: hidden;
-  border-radius: 10px;
-`;
-
-const ErrorHelper = styled.div`
-  position: absolute;
-  right: -185px;
-  top: 8px;
-  height: 30px;
-  width: 170px;
-  user-select: none;
-  background: #272731;
-  font-family: "Work Sans", sans-serif;
-  font-size: 12px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: #ff3b62;
-  border-radius: 3px;
-
-  > div {
-    background: #272731;
-    height: 15px;
-    width: 15px;
-    position: absolute;
-    left: -3px;
-    top: 7px;
-    transform: rotate(45deg);
-    z-index: -1;
-  }
-`;
-
-const Line = styled.div`
-  min-height: 3px;
-  width: 100px;
-  z-index: 999;
-  background: #ffffff22;
-  margin: 30px 0px 30px;
-`;
-
-const Button = styled.button`
-  width: 200px;
-  min-height: 30px;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  font-family: "Work Sans", sans-serif;
-  cursor: pointer;
-  margin: 9px auto;
-  border-radius: 2px;
-  border: 0;
-  background: #819bfd;
-  color: white;
-  font-weight: 500;
-  font-size: 14px;
-`;
-
-const InputWrapper = styled.div`
-  position: relative;
-`;
-
-const Input = styled.input`
-  width: 200px;
-  font-family: "Work Sans", sans-serif;
-  margin: 8px 0px;
-  height: 30px;
-  padding: 8px;
-  background: #ffffff12;
-  color: #ffffff;
-  border: ${(props: { valid?: boolean }) =>
-    props.valid ? "0" : "1px solid #ff3b62"};
-  border-radius: 2px;
-  font-size: 14px;
-`;
-
-const Prompt = styled.div`
-  font-family: "Work Sans", sans-serif;
-  font-weight: 500;
-  font-size: 15px;
-  margin-bottom: 18px;
-`;
-
-const Logo = styled.img`
-  width: 140px;
-  margin-top: 50px;
-  margin-bottom: 60px;
-  user-select: none;
-`;
-
-const StatusText = styled.div<{ lessPadding?: boolean }>`
-  padding: ${(props) => (props.lessPadding ? "10px" : "18px")} 40px;
-  font-family: "Work Sans", sans-serif;
-  font-size: 14px;
-  line-height: 160%;
-  color: #aaaabb;
-  text-align: center;
-`;
-
-const FormWrapper = styled.div`
-  width: calc(100% - 8px);
-  height: calc(100% - 8px);
-  background: #111114;
-  z-index: 1;
-  border-radius: 10px;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-`;
-
-const GradientBg = styled.div`
-  background: linear-gradient(#8ce1ff, #a59eff, #fba8ff);
-  width: 180%;
-  height: 180%;
-  position: absolute;
-  top: -40%;
-  left: -40%;
-  animation: flip 6s infinite linear;
-  @keyframes flip {
-    from {
-      transform: rotate(0deg);
-    }
-    to {
-      transform: rotate(360deg);
-    }
-  }
-`;
-
-const LoginPanel = styled.div`
-  width: 330px;
-  height: 470px;
-  background: white;
+const Wrapper = styled.div`
+  width: 500px;
   margin-top: -20px;
-  border-radius: 10px;
-  display: flex;
-  justify-content: center;
   position: relative;
-  align-items: center;
+  padding: 25px;
+  border-radius: 5px;
+  font-size: 13px;
 `;
 
-const StyledLogin = styled.div`
+const StyledRegister = styled.div`
   display: flex;
   align-items: center;
   justify-content: center;
@@ -355,4 +256,4 @@ const StyledLogin = styled.div`
   top: 0;
   left: 0;
   background: #111114;
-`;
+`;

+ 2 - 2
dashboard/src/main/home/Home.tsx

@@ -407,7 +407,7 @@ const Home: React.FC<Props> = props => {
           />
           {user?.isPorterUser || overrideInfraTabEnabled({
             projectID: currentProject?.id,
-          }) ? (
+          }) && (
             <Route
               path="/infrastructure"
               render={() => {
@@ -418,7 +418,7 @@ const Home: React.FC<Props> = props => {
                 );
               }}
             />
-          ) : null}
+          )}
           <Route
             path="/dashboard"
             render={() => {

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

@@ -84,8 +84,15 @@ const ModalHandler: React.FC<{
     }
   }, [currentModal, currentProject]);
 
+  const renderModal = () => {
+    if (modal && typeof modal !== 'string') {
+      return modal;
+    }
+  }
+
   return (
     <>
+      {renderModal()}
       {modal === "RedirectToOnboardingModal" && (
         <Modal width="600px" height="180px" title="You're almost ready...">
           <RedirectToOnboardingModal />

+ 151 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettingsModal.tsx

@@ -0,0 +1,151 @@
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+
+import { Context } from "shared/Context";
+import api from "shared/api";
+
+import Modal from "main/home/modals/Modal";
+import Input from "components/porter/Input";
+import Button from "components/porter/Button";
+import Spacer from "components/porter/Spacer";
+
+type Props = {
+};
+
+const ClusterSettingsModal: React.FC<Props> = ({
+}) => {
+  const { 
+    setCurrentModal, 
+    currentCluster, 
+    currentProject,
+    setShouldRefreshClusters,
+  } = useContext(Context);
+  const [clusterName, setClusterName] = useState("");
+  const [status, setStatus] = useState("");
+
+  useEffect(() => {
+    setClusterName(currentCluster.vanity_name || currentCluster.name);
+  }, []);
+
+  const renameCluster = async () => {
+    setStatus("loading");
+    try {
+      const res = await api.renameCluster(
+        "<token>",
+        { name: clusterName },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      );
+      setStatus("success");
+      setShouldRefreshClusters(true);
+    } catch (err) {
+      setStatus("error");
+      console.log(err);
+    }
+  }
+
+  return (
+    <Modal
+      width="600px"
+      height="auto"
+      onRequestClose={() => setCurrentModal(null, null)}
+      title="Cluster name"
+    >
+      <Spacer height="15px" />
+      <Flex>
+        <IconWrapper>
+        <svg
+          width="18"
+          height="18"
+          viewBox="0 0 19 19"
+          fill="none"
+          xmlns="http://www.w3.org/2000/svg"
+        >
+          <path
+            d="M15.207 12.4403C16.8094 12.4403 18.1092 11.1414 18.1092 9.53907C18.1092 7.93673 16.8094 6.63782 15.207 6.63782"
+            stroke="white"
+            strokeWidth="1.5"
+            strokeLinecap="round"
+            stroke-linejoin="round"
+          />
+          <path
+            d="M3.90217 12.4403C2.29983 12.4403 1 11.1414 1 9.53907C1 7.93673 2.29983 6.63782 3.90217 6.63782"
+            stroke="white"
+            strokeWidth="1.5"
+            strokeLinecap="round"
+            stroke-linejoin="round"
+          />
+          <path
+            fillRule="evenodd"
+            clipRule="evenodd"
+            d="M9.54993 13.4133C7.4086 13.4133 5.69168 11.6964 5.69168 9.55417C5.69168 7.41284 7.4086 5.69592 9.54993 5.69592C11.6913 5.69592 13.4082 7.41284 13.4082 9.55417C13.4082 11.6964 11.6913 13.4133 9.54993 13.4133Z"
+            stroke="white"
+            strokeWidth="1.5"
+            strokeLinecap="round"
+            stroke-linejoin="round"
+          />
+          <path
+            d="M6.66895 15.207C6.66895 16.8094 7.96787 18.1092 9.5702 18.1092C11.1725 18.1092 12.4715 16.8094 12.4715 15.207"
+            stroke="white"
+            strokeWidth="1.5"
+            strokeLinecap="round"
+            stroke-linejoin="round"
+          />
+          <path
+            d="M6.66895 3.90217C6.66895 2.29983 7.96787 1 9.5702 1C11.1725 1 12.4715 2.29983 12.4715 3.90217"
+            stroke="white"
+            strokeWidth="1.5"
+            strokeLinecap="round"
+            stroke-linejoin="round"
+          />
+          <path
+            fillRule="evenodd"
+            clipRule="evenodd"
+            d="M5.69591 9.54996C5.69591 7.40863 7.41283 5.69171 9.55508 5.69171C11.6964 5.69171 13.4133 7.40863 13.4133 9.54996C13.4133 11.6913 11.6964 13.4082 9.55508 13.4082C7.41283 13.4082 5.69591 11.6913 5.69591 9.54996Z"
+            stroke="white"
+            strokeWidth="1.5"
+            strokeLinecap="round"
+            stroke-linejoin="round"
+          />
+        </svg>
+        </IconWrapper>
+        <Spacer inline />
+        <Input 
+          placeholder="ex: my-cluster" 
+          width="100%"
+          value={clusterName}
+          setValue={setClusterName}
+        />
+      </Flex>
+      <Spacer y={1} />
+      <Button 
+        onClick={renameCluster}
+        disabled={clusterName === ""}
+        status={status}
+        helperText="Note: The vanity name for your cluster will not change the cluster's name in your cloud provider."
+      >
+        Save
+      </Button>
+    </Modal>
+  );
+};
+
+export default ClusterSettingsModal;
+
+const IconWrapper = styled.div`
+  min-width: 35px;
+  height: 35px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border: 1px solid #494b4f;
+  border-radius: 5px;
+  cursor: not-allowed;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;

+ 94 - 8
dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx

@@ -1,7 +1,7 @@
 import React, { useContext, useEffect, useState } from "react";
 import styled from "styled-components";
 import { useLocation } from "react-router";
-import settings from "assets/settings-centered.svg";
+import settings from "assets/settings.svg";
 
 import api from "shared/api";
 import { DetailedIngressError } from "shared/types";
@@ -18,6 +18,7 @@ import NodeList from "./NodeList";
 import { NamespaceList } from "./NamespaceList";
 import ClusterSettings from "./ClusterSettings";
 import Metrics from "./Metrics";
+import ClusterSettingsModal from "./ClusterSettingsModal";
 
 import CopyToClipboard from "components/CopyToClipboard";
 import Loading from "components/Loading";
@@ -31,7 +32,7 @@ var tabOptions: {
 }[] = [{ label: "Additional settings", value: "settings" }];
 
 export const Dashboard: React.FunctionComponent = () => {
-  const [currentTab, setCurrentTab] = useState<TabEnum>("nodes");
+  const [currentTab, setCurrentTab] = useState<TabEnum>("settings");
   const [currentTabOptions, setCurrentTabOptions] = useState(tabOptions);
   const [isAuthorized] = useAuth();
   const location = useLocation();
@@ -59,7 +60,6 @@ export const Dashboard: React.FunctionComponent = () => {
               clusterId={context.currentCluster.id}
               credentialId={context.currentCluster.cloud_provider_credential_identifier}
             />
-            <Div />
           </>
         );
       default:
@@ -229,8 +229,74 @@ export const Dashboard: React.FunctionComponent = () => {
   return (
     <>
       <DashboardHeader
-        image={settings}
-        title={context.currentCluster.vanity_name || context.currentCluster.name}
+        title={
+          <Flex>
+            <Flex>
+              <svg
+                width="23"
+                height="23"
+                viewBox="0 0 19 19"
+                fill="none"
+                xmlns="http://www.w3.org/2000/svg"
+              >
+                <path
+                  d="M15.207 12.4403C16.8094 12.4403 18.1092 11.1414 18.1092 9.53907C18.1092 7.93673 16.8094 6.63782 15.207 6.63782"
+                  stroke="white"
+                  strokeWidth="1.5"
+                  strokeLinecap="round"
+                  stroke-linejoin="round"
+                />
+                <path
+                  d="M3.90217 12.4403C2.29983 12.4403 1 11.1414 1 9.53907C1 7.93673 2.29983 6.63782 3.90217 6.63782"
+                  stroke="white"
+                  strokeWidth="1.5"
+                  strokeLinecap="round"
+                  stroke-linejoin="round"
+                />
+                <path
+                  fillRule="evenodd"
+                  clipRule="evenodd"
+                  d="M9.54993 13.4133C7.4086 13.4133 5.69168 11.6964 5.69168 9.55417C5.69168 7.41284 7.4086 5.69592 9.54993 5.69592C11.6913 5.69592 13.4082 7.41284 13.4082 9.55417C13.4082 11.6964 11.6913 13.4133 9.54993 13.4133Z"
+                  stroke="white"
+                  strokeWidth="1.5"
+                  strokeLinecap="round"
+                  stroke-linejoin="round"
+                />
+                <path
+                  d="M6.66895 15.207C6.66895 16.8094 7.96787 18.1092 9.5702 18.1092C11.1725 18.1092 12.4715 16.8094 12.4715 15.207"
+                  stroke="white"
+                  strokeWidth="1.5"
+                  strokeLinecap="round"
+                  stroke-linejoin="round"
+                />
+                <path
+                  d="M6.66895 3.90217C6.66895 2.29983 7.96787 1 9.5702 1C11.1725 1 12.4715 2.29983 12.4715 3.90217"
+                  stroke="white"
+                  strokeWidth="1.5"
+                  strokeLinecap="round"
+                  stroke-linejoin="round"
+                />
+                <path
+                  fillRule="evenodd"
+                  clipRule="evenodd"
+                  d="M5.69591 9.54996C5.69591 7.40863 7.41283 5.69171 9.55508 5.69171C11.6964 5.69171 13.4133 7.40863 13.4133 9.54996C13.4133 11.6913 11.6964 13.4082 9.55508 13.4082C7.41283 13.4082 5.69591 11.6913 5.69591 9.54996Z"
+                  stroke="white"
+                  strokeWidth="1.5"
+                  strokeLinecap="round"
+                  stroke-linejoin="round"
+                />
+              </svg>
+              <Spacer inline />
+              {context.currentCluster.vanity_name || context.currentCluster.name}
+              <Spacer inline />
+            </Flex>
+            <SettingsIcon onClick={() => {
+              context.setCurrentModal(<ClusterSettingsModal />);
+            }}>
+              <img src={settings} />
+            </SettingsIcon>
+          </Flex>
+        }
         description={
           ingressIp ? (
             <>{renderIngressIp(ingressIp, ingressError)}</>
@@ -247,9 +313,29 @@ export const Dashboard: React.FunctionComponent = () => {
   );
 };
 
-const Div = styled.div`
-  width: 100%;
-  height: 50px;
+const SettingsIcon = styled.div`
+  width: 30px;
+  height: 30px;
+  margin-left: 3px;
+  cursor: pointer;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 40px;
+  margin-bottom: -2px;
+  :hover {
+    background: #ffffff18;
+  }
+  > img {
+    width: 22px;
+    opacity: 0.4;
+    margin-bottom: -4px;
+  }
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
 `;
 
 const Br = styled.div`

+ 8 - 2
dashboard/src/main/home/cluster-dashboard/dashboard/ProvisionerStatus.tsx

@@ -25,7 +25,7 @@ const ProvisionerStatus: React.FC<Props> = ({
   // Continuously poll provisioning status
   const pollProvisioningStatus = async () => {
     try {
-      const res = await api.getClusterStatus(
+      const res = await api.getClusterState(
         "<token>",
         {},
         {
@@ -47,9 +47,15 @@ const ProvisionerStatus: React.FC<Props> = ({
         default:
           setProgress(1);
       }
-    } catch (error) {}
+    } catch (err) {
+      console.log(err);
+    }
   };
 
+  useEffect(() => {
+    pollProvisioningStatus(); 
+  }, []);
+
   return (
     <StyledProvisionerStatus>
       <HeaderSection>

+ 5 - 24
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupList.tsx

@@ -10,6 +10,8 @@ import Loading from "components/Loading";
 import { getQueryParam, pushQueryParams } from "shared/routing";
 import { RouteComponentProps, withRouter } from "react-router";
 
+import Placeholder from "components/Placeholder";
+
 type Props = RouteComponentProps & {
   currentCluster: ClusterType;
   namespace: string;
@@ -107,15 +109,15 @@ const EnvGroupList: React.FunctionComponent<Props> = (props) => {
       );
     } else if (hasError) {
       return (
-        <Placeholder>
+        <Placeholder height="370px">
           <i className="material-icons">error</i> Error connecting to cluster.
         </Placeholder>
       );
     } else if (envGroups.length === 0) {
       return (
-        <Placeholder>
+        <Placeholder height="370px">
           <i className="material-icons">category</i>
-          No environment groups found in this namespace.
+          No environment groups found with the given filters.
         </Placeholder>
       );
     }
@@ -135,27 +137,6 @@ const EnvGroupList: React.FunctionComponent<Props> = (props) => {
 
 export default withRouter(EnvGroupList);
 
-const Placeholder = styled.div`
-  width: 100%;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  color: #ffffff44;
-  background: #26282f;
-  border-radius: 5px;
-  height: 370px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: #ffffff44;
-  font-size: 13px;
-
-  > i {
-    font-size: 16px;
-    margin-right: 12px;
-  }
-`;
-
 const LoadingWrapper = styled.div`
   padding-top: 100px;
 `;

+ 3 - 0
dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx

@@ -31,6 +31,7 @@ import useAuth from "shared/auth/useAuth";
 import { fillWithDeletedVariables } from "components/porter-form/utils";
 import DynamicLink from "components/DynamicLink";
 import DocsHelper from "components/DocsHelper";
+import Spacer from "components/porter/Spacer";
 
 type PropsType = WithAuthProps & {
   namespace: string;
@@ -435,6 +436,8 @@ export const ExpandedEnvGroupFC = ({
         </TitleSection>
       </HeaderWrapper>
 
+      <Spacer y={1} />
+
       {isDeleting ? (
         <>
           <LineBreak />

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

@@ -575,8 +575,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
       );
     }
 
-    //if (currentChart?.git_action_config?.git_repo && !isStack) {
-    if (true) {
+    if (currentChart?.git_action_config?.git_repo && !isStack) {
       rightTabOptions.push({
         label: "Build Settings",
         value: "build-settings",

+ 2 - 18
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx

@@ -6,7 +6,7 @@ import { Context } from "shared/Context";
 import JobResource from "./JobResource";
 import useAuth from "shared/auth/useAuth";
 import usePagination from "shared/hooks/usePagination";
-import Selector from "components/Selector";
+import Placeholder from "components/Placeholder";
 
 type PropsType = {
   jobs: any[];
@@ -73,7 +73,7 @@ const JobListFC = (props: PropsType): JSX.Element => {
   if (!props.jobs?.length) {
     return (
       <JobListWrapper>
-        <Placeholder>
+        <Placeholder height="350px">
           <i className="material-icons">category</i>
           There are no jobs currently running.
         </Placeholder>
@@ -196,22 +196,6 @@ const PageCounter = styled.span`
   margin: 0 5px;
 `;
 
-const Placeholder = styled.div`
-  width: 100%;
-  min-height: 250px;
-  height: 30vh;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: #ffffff44;
-  font-size: 14px;
-
-  > i {
-    font-size: 18px;
-    margin-right: 10px;
-  }
-`;
-
 const JobListWrapper = styled.div`
   width: 100%;
   height: calc(100% - 65px);

+ 0 - 1
dashboard/src/main/home/dashboard/ClusterList.tsx

@@ -27,7 +27,6 @@ const ClusterList: React.FC<Props> = ({}) => {
       { id: currentProject.id },
     )
       .then(({ data }) => {
-        console.log(data);
         setClusters(data);
         setIsLoading(false);
       })

+ 2 - 1
dashboard/src/main/home/dashboard/ClusterSection.tsx

@@ -8,6 +8,7 @@ import Banner from "components/Banner";
 import ProvisionerFlow from "components/ProvisionerFlow";
 import ClusterList from "./ClusterList";
 import TitleSection from "components/TitleSection";
+import Spacer from "components/porter/Spacer";
 
 type Props = {
 };
@@ -81,7 +82,7 @@ const ClusterSection = (props: Props) => {
             Provision a new cluster
           </Title>
         </TitleSection>
-        <Br height="7px" />
+        <Spacer y={1} />
         <Banner>
           You have currently provisioned {usage?.current.cluster || "0"} out of {usage?.limit.clusters || "0"} clusters for this project.
         </Banner>

+ 2 - 2
dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx

@@ -1,7 +1,7 @@
 import React, { useContext, useState } from "react";
 import styled from "styled-components";
 import _ from "lodash";
-import randomWords from "random-words";
+import { generateSlug } from "random-word-slugs";
 import { RouteComponentProps, withRouter } from "react-router";
 
 import api from "shared/api";
@@ -66,7 +66,7 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
   const [buildConfig, setBuildConfig] = useState();
 
   const generateRandomName = () => {
-    const randomTemplateName = randomWords({ exactly: 3, join: "-" });
+    const randomTemplateName = generateSlug();
     return randomTemplateName;
   };
 

+ 10 - 8
dashboard/src/main/home/modals/Modal.tsx

@@ -103,8 +103,8 @@ const CloseButton = styled.div`
   justify-content: center;
   z-index: 1;
   border-radius: 50%;
-  right: 15px;
-  top: 12px;
+  right: 12px;
+  top: 10px;
   cursor: pointer;
   :hover {
     background-color: #ffffff11;
@@ -131,16 +131,18 @@ const Overlay = styled.div`
   justify-content: center;
 `;
 
-const StyledModal = styled.div`
+const StyledModal = styled.div<{
+  width: string;
+  height: string;
+}>`
   position: absolute;
-  width: ${(props: { width?: string; height?: string }) =>
-    props.width ? props.width : "760px"};
+  width: ${props => props.width || "760px"};
   max-width: 80vw;
-  height: ${(props: { width?: string; height?: string }) =>
-    props.height ? props.height : "425px"};
+  height: ${props => props.height || "425px"};
   max-height: calc(100vh - 30px);
   overflow: visible;
-  padding: 25px 32px;
+  padding: 25px;
+  padding-bottom: 30px;
   z-index: 999;
   font-size: 13px;
   border-radius: 10px;

+ 1 - 1
dashboard/src/shared/ace-porter-theme.js

@@ -1,4 +1,4 @@
-import ace from "brace";
+import ace from 'ace-builds/src-noconflict/ace';
 
 ace["define"](
   "ace/theme/porter",

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

@@ -113,6 +113,18 @@ const updateCluster = baseApi<
   return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}`;
 });
 
+const renameCluster = baseApi<
+  {
+    name: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>("POST", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/rename`;
+});
+
 const createAzureIntegration = baseApi<
   {
     azure_client_id: string;
@@ -920,6 +932,13 @@ const deleteContract = baseApi<
   return `/api/projects/${project_id}/contracts/${revision_id}`;
 });
 
+const getClusterState = baseApi<
+  {},
+  { project_id: number, cluster_id: number }
+>("GET", ({ project_id, cluster_id }) => {
+  return `/api/projects/${project_id}/clusters/${cluster_id}/state`;
+});
+
 const provisionInfra = baseApi<
   {
     kind: string;
@@ -1381,6 +1400,9 @@ const logOutUser = baseApi("POST", "/api/logout");
 const registerUser = baseApi<{
   email: string;
   password: string;
+  first_name: string;
+  last_name: string;
+  company_name: string;
 }>("POST", "/api/users");
 
 const rollbackChart = baseApi<
@@ -1411,6 +1433,17 @@ const uninstallTemplate = baseApi<
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${name}/0`;
 });
 
+const updateUserInfo = baseApi<
+  {
+    first_name: string;
+    last_name: string;
+    company_name: string;
+  },
+  {}
+>("POST", (pathParams) => {
+  return `/api/users/update/info`;
+});
+
 const updateUser = baseApi<
   {
     rawKubeConfig?: string;
@@ -2391,6 +2424,7 @@ export default {
   createAWSIntegration,
   overwriteAWSIntegration,
   updateCluster,
+  renameCluster,
   createAzureIntegration,
   createGitlabIntegration,
   createEmailVerification,
@@ -2477,6 +2511,7 @@ export default {
   getPodByName,
   getMatchingPods,
   getAllReleasePods,
+  getClusterState,
   getMetrics,
   getNamespaces,
   getNGINXIngresses,
@@ -2510,6 +2545,7 @@ export default {
   registerUser,
   rollbackChart,
   uninstallTemplate,
+  updateUserInfo,
   updateUser,
   renameConfigMap,
   updateConfigMap,

+ 1 - 1
dashboard/src/shared/error_handling/logger.ts

@@ -33,7 +33,7 @@ function buildLogger(scope: string = "global") {
       if (typeof currentSeverity === "string") {
         acc[currentSeverity] = logFunctionBuilder(
           scope,
-          Sentry.Severity.fromString(currentSeverity)
+          Sentry.Severity.Info
         );
       }
 

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

@@ -339,7 +339,7 @@ export interface CapabilityType {
 export interface ContextProps {
   currentModal?: string;
   currentModalData: any;
-  setCurrentModal: (currentModal: string, currentModalData?: any) => void;
+  setCurrentModal: (currentModal: any, currentModalData?: any) => void;
   currentOverlay: {
     message: string;
     onYes: any;

+ 1 - 1
go.mod

@@ -74,7 +74,7 @@ require (
 	github.com/glebarez/sqlite v1.6.0
 	github.com/nats-io/nats.go v1.24.0
 	github.com/open-policy-agent/opa v0.44.0
-	github.com/porter-dev/api-contracts v0.0.43
+	github.com/porter-dev/api-contracts v0.0.47
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d
 	github.com/xanzy/go-gitlab v0.68.0

+ 2 - 2
go.sum

@@ -1466,8 +1466,8 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
-github.com/porter-dev/api-contracts v0.0.43 h1:X+LWp19k/NR2/BJxmA8xJ3mEmmORI4MeJJpypkArU6Q=
-github.com/porter-dev/api-contracts v0.0.43/go.mod h1:qr2L58mJLr5DUGV5OPw3REiSrQvJq6TgkKyEWP95dyU=
+github.com/porter-dev/api-contracts v0.0.47 h1:27oAGW8i+SXQFF3LZG0FrGz7KUolNenbjRPPn0/V+og=
+github.com/porter-dev/api-contracts v0.0.47/go.mod h1:qr2L58mJLr5DUGV5OPw3REiSrQvJq6TgkKyEWP95dyU=
 github.com/porter-dev/switchboard v0.0.0-20221019155755-67ff2bf04935 h1:hfb3nt3AJXIBbevu6ARTg9SdOkMP6WLbKBiG5hT5rcc=
 github.com/porter-dev/switchboard v0.0.0-20221019155755-67ff2bf04935/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=

+ 1 - 0
go.work.sum

@@ -16,3 +16,4 @@ golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
 golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
 golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=

+ 7 - 0
internal/models/user.go

@@ -13,6 +13,10 @@ type User struct {
 	Password      string `json:"password"`
 	EmailVerified bool   `json:"email_verified"`
 
+	FirstName   string `json:"first_name"`
+	LastName    string `json:"last_name"`
+	CompanyName string `json:"company_name"`
+
 	// ID of oauth integration for github connection (optional)
 	GithubAppIntegrationID uint
 
@@ -27,5 +31,8 @@ func (u *User) ToUserType() *types.User {
 		ID:            u.ID,
 		Email:         u.Email,
 		EmailVerified: u.EmailVerified,
+		FirstName:     u.FirstName,
+		LastName:      u.LastName,
+		CompanyName:   u.CompanyName,
 	}
 }

+ 9 - 9
services/preview_env_setup_job/go.mod

@@ -4,7 +4,8 @@ go 1.19
 
 require (
 	github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd
-	github.com/porter-dev/porter v0.44.0
+	github.com/porter-dev/porter v0.45.4
+	golang.org/x/crypto v0.6.0
 )
 
 require (
@@ -78,7 +79,7 @@ require (
 	github.com/jmoiron/sqlx v1.3.5 // indirect
 	github.com/josharian/intern v1.0.0 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
-	github.com/klauspost/compress v1.15.7 // indirect
+	github.com/klauspost/compress v1.16.0 // indirect
 	github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
 	github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
 	github.com/lib/pq v1.10.7 // indirect
@@ -122,18 +123,17 @@ require (
 	github.com/xeipuuv/gojsonschema v1.2.0 // indirect
 	github.com/xlab/treeprint v1.1.0 // indirect
 	go.starlark.net v0.0.0-20220328144851-d1966c6b9fcd // indirect
-	golang.org/x/crypto v0.4.0 // indirect
-	golang.org/x/net v0.4.0 // indirect
+	golang.org/x/net v0.6.0 // indirect
 	golang.org/x/oauth2 v0.3.0 // indirect
 	golang.org/x/sync v0.1.0 // indirect
-	golang.org/x/sys v0.3.0 // indirect
-	golang.org/x/term v0.3.0 // indirect
-	golang.org/x/text v0.5.0 // indirect
-	golang.org/x/time v0.0.0-20220609170525-579cf78fd858 // indirect
+	golang.org/x/sys v0.5.0 // indirect
+	golang.org/x/term v0.5.0 // indirect
+	golang.org/x/text v0.7.0 // indirect
+	golang.org/x/time v0.3.0 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
 	google.golang.org/genproto v0.0.0-20221202195650-67e5cbc046fd // indirect
 	google.golang.org/grpc v1.50.1 // indirect
-	google.golang.org/protobuf v1.28.1 // indirect
+	google.golang.org/protobuf v1.29.0 // indirect
 	gopkg.in/inf.v0 v0.9.1 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect

+ 18 - 18
services/preview_env_setup_job/go.sum

@@ -647,8 +647,8 @@ github.com/kisielk/errcheck v1.6.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
 github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
 github.com/klauspost/compress v1.13.5/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
-github.com/klauspost/compress v1.15.7 h1:7cgTQxJCU/vy+oP/E3B9RGbQTgbiVzIJWIKOLoAsPok=
-github.com/klauspost/compress v1.15.7/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
+github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4=
+github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@@ -864,8 +864,8 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
-github.com/porter-dev/porter v0.44.0 h1:Y7YYAj1ZM3timMPTG6GrlYe6UiRvX8xtunwJW63b1v0=
-github.com/porter-dev/porter v0.44.0/go.mod h1:GoIoc3h08jxGcgCwsTq+C6dt6jv6mO9OQRdZBrt8iR4=
+github.com/porter-dev/porter v0.45.4 h1:UWEP0SGnQLiIUEOWyN0VTtdbFnj4ClfTiDktDvlTCzc=
+github.com/porter-dev/porter v0.45.4/go.mod h1:yNVY4CJWhahEf5RPA6ynQK+qu9LKU5ptqlYzjFL7eVs=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
 github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
 github.com/poy/onpar v0.0.0-20190519213022-ee068f8ea4d1 h1:oL4IBbcqwhhNWh31bjOX8C/OCy0zs9906d/VUru+bqg=
@@ -1137,8 +1137,8 @@ golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5y
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
-golang.org/x/crypto v0.4.0 h1:UVQgzMY87xqpKNgb+kDsll2Igd33HszWHFLmpaRMq/8=
-golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80=
+golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
+golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -1233,8 +1233,8 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
 golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
-golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU=
-golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
+golang.org/x/net v0.6.0 h1:L4ZwwTvKW9gr0ZMS1yrHD9GZhIuVjOBBnaKH+SPQK0Q=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -1360,16 +1360,16 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
-golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
-golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI=
-golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
+golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -1380,16 +1380,16 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM=
-golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20220609170525-579cf78fd858 h1:Dpdu/EMxGMFgq0CeYMh4fazTD2vtlZRYE7wyynxJb9U=
-golang.org/x/time v0.0.0-20220609170525-579cf78fd858/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
+golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -1649,8 +1649,8 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
 google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
-google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/protobuf v1.29.0 h1:44S3JjaKmLEE4YIkjzexaP+NzZsudE3Zin5Njn/pYX0=
+google.golang.org/protobuf v1.29.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
 gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.