Переглянути джерело

Merge branch 'master' into staging

jusrhee 3 роки тому
батько
коміт
6141359888
37 змінених файлів з 1481 додано та 1177 видалено
  1. 3 0
      .gitignore
  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. 1 0
      api/server/handlers/project/create_test.go
  6. 1 1
      api/server/handlers/project_integration/create_aws.go
  7. 5 2
      api/server/handlers/user/create.go
  8. 49 0
      api/server/handlers/user/update_user_info.go
  9. 25 0
      api/server/router/user.go
  10. 14 2
      api/types/user.go
  11. 5 10
      dashboard/package-lock.json
  12. 6 7
      dashboard/package.json
  13. BIN
      dashboard/src/assets/blog.png
  14. BIN
      dashboard/src/assets/community.png
  15. BIN
      dashboard/src/assets/docs.png
  16. 1 1
      dashboard/src/components/YamlEditor.tsx
  17. 10 1
      dashboard/src/components/porter/Button.tsx
  18. 31 0
      dashboard/src/components/porter/Container.tsx
  19. 70 11
      dashboard/src/components/porter/Input.tsx
  20. 37 0
      dashboard/src/components/porter/Link.tsx
  21. 10 1
      dashboard/src/components/porter/Spacer.tsx
  22. 49 4
      dashboard/src/main/Main.tsx
  23. 259 392
      dashboard/src/main/auth/Login.tsx
  24. 303 381
      dashboard/src/main/auth/Register.tsx
  25. 316 0
      dashboard/src/main/auth/SetInfo.tsx
  26. 180 279
      dashboard/src/main/auth/VerifyEmail.tsx
  27. 2 2
      dashboard/src/main/home/Home.tsx
  28. 5 24
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupList.tsx
  29. 1 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  30. 2 18
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx
  31. 1 1
      dashboard/src/shared/ace-porter-theme.js
  32. 15 0
      dashboard/src/shared/api.tsx
  33. 1 1
      go.mod
  34. 2 2
      go.sum
  35. 7 0
      internal/models/user.go
  36. 9 9
      services/preview_env_setup_job/go.mod
  37. 18 18
      services/preview_env_setup_job/go.sum

+ 3 - 0
.gitignore

@@ -17,6 +17,9 @@ bin
 openapi.yaml
 .idea
 vendor
+zarf/helm/charts
+*.env
+porter
 
 # Local docs directories
 /docs/.obsidian

+ 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)
 

+ 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())
+}

+ 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{

+ 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"`
+}

+ 5 - 10
dashboard/package-lock.json

@@ -3572,11 +3572,6 @@
       "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
       "dev": true
     },
-    "brace": {
-      "version": "0.11.1",
-      "resolved": "https://registry.npmjs.org/brace/-/brace-0.11.1.tgz",
-      "integrity": "sha512-Fc8Ne62jJlKHiG/ajlonC4Sd66Pq68fFwK4ihJGNZpGqboc324SQk+lRvMzpPRuJOmfrJefdG8/7JdWX4bzJ2Q=="
-    },
     "brace-expansion": {
       "version": "1.1.11",
       "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@@ -8286,12 +8281,12 @@
       }
     },
     "react-ace": {
-      "version": "9.5.0",
-      "resolved": "https://registry.npmjs.org/react-ace/-/react-ace-9.5.0.tgz",
-      "integrity": "sha512-4l5FgwGh6K7A0yWVMQlPIXDItM4Q9zzXRqOae8KkCl6MkOob7sC1CzHxZdOGvV+QioKWbX2p5HcdOVUv6cAdSg==",
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/react-ace/-/react-ace-8.0.0.tgz",
+      "integrity": "sha512-EvU14vXbZpAenb1ZVKdn8yTQs/shZ9RghFulHtt67bBXT6sjrNHcfOEXHYtSEmwMb6pQVVNNuulzzd8o+Uouig==",
       "requires": {
-        "ace-builds": "^1.4.13",
-        "diff-match-patch": "^1.0.5",
+        "ace-builds": "^1.4.6",
+        "diff-match-patch": "^1.0.4",
         "lodash.get": "^4.4.2",
         "lodash.isequal": "^4.5.0",
         "prop-types": "^15.7.2"

+ 6 - 7
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",
@@ -45,11 +44,11 @@
     "markdown-to-jsx": "^7.0.1",
     "qs": "^6.9.4",
     "random-word-slugs": "^0.1.6",
-    "react": "^16.13.1",
-    "react-ace": "^9.1.3",
+    "react": "^16.14.0",
+    "react-ace": "^8.0.0",
     "react-color": "^2.19.3",
     "react-datepicker": "^4.8.0",
-    "react-dom": "^16.13.1",
+    "react-dom": "^16.14.0",
     "react-error-boundary": "^3.1.3",
     "react-hot-toast": "^2.4.0",
     "react-infinite-scroll-component": "^6.1.0",
@@ -95,10 +94,10 @@
     "@types/material-ui": "^0.21.8",
     "@types/node": "^12.12.62",
     "@types/qs": "^6.9.5",
-    "@types/react": "^16.14.14",
+    "@types/react": "^16.14.35",
     "@types/react-color": "^3.0.6",
     "@types/react-datepicker": "^4.4.2",
-    "@types/react-dom": "^16.9.8",
+    "@types/react-dom": "^16.9.18",
     "@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


+ 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" }}

+ 10 - 1
dashboard/src/components/porter/Button.tsx

@@ -11,6 +11,8 @@ type Props = {
   helperText?: string;
   loadingText?: string;
   successText?: string;
+  width?: string;
+  height?: string;
 };
 
 const Button: React.FC<Props> = ({
@@ -21,6 +23,8 @@ const Button: React.FC<Props> = ({
   helperText,
   loadingText,
   successText,
+  width,
+  height,
 }) => {
   const renderStatus = () => {
     switch(status) {
@@ -57,6 +61,8 @@ const Button: React.FC<Props> = ({
       <StyledButton
         disabled={disabled}
         onClick={() => !disabled && onClick()}
+        width={width}
+        height={height}
       >
         <Text>{children}</Text>
       </StyledButton>
@@ -121,8 +127,11 @@ const Text = styled.div`
 
 const StyledButton = styled.button<{
   disabled: boolean;
+  width: string;
+  height: string;
 }>`
-  height: 35px;
+  height: ${props => props.height || "35px"};
+  width: ${props => props.width || "auto"};
   font-size: 13px;
   cursor: ${props => props.disabled ? "not-allowed" : "pointer"};
   padding: 15px;

+ 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;
+`;

+ 70 - 11
dashboard/src/components/porter/Input.tsx

@@ -6,6 +6,11 @@ type Props = {
   width?: string;
   value: string;
   setValue: (value: string) => void;
+  label?: string;
+  height?: string;
+  type?: string;
+  error?: string;
+  children?: React.ReactNode;
 };
 
 const Input: React.FC<Props> = ({
@@ -13,32 +18,86 @@ const Input: React.FC<Props> = ({
   width,
   value,
   setValue,
+  label,
+  height,
+  type,
+  error,
+  children,
 }) => {
   return (
-    <StyledInput
-      value={value}
-      onChange={e => setValue(e.target.value)}
-      placeholder={placeholder}
-      width={width}
-    />
+    <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: 35px;
+  height: ${props => props.height || "35px"};
   padding: 5px 10px;
   width: ${props => props.width || "200px"};
-  color: white;
-  font-saize: 13px;
+  color: #ffffff;
+  font-size: 13px;
   outline: none;
   border-radius: 5px;
   background: #26292e;
-  border: 1px solid #494b4f;
+  border: 1px solid ${props => props.hasError ? "#ff3b62" : "#494b4f"};
   :hover {
-    border: 1px solid #7a7b80;
+    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;
+`;

+ 10 - 1
dashboard/src/components/porter/Spacer.tsx

@@ -4,12 +4,14 @@ 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 = () => {
@@ -18,11 +20,18 @@ const Spacer: React.FC<Props> = ({
     }
     return null
   };
+
+  const getCalcWidth = () => {
+    if (x) {
+      return 15 * x + "px";
+    }
+    return "15px";
+  };
   
   return (
     <StyledSpacer
       height={height || getCalcHeight()}
-      width={inline && "15px"}
+      width={inline && getCalcWidth()}
     />
   );
 };

+ 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={() => {

+ 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;
 `;

+ 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);

+ 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",

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

@@ -1400,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<
@@ -1430,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;
@@ -2531,6 +2545,7 @@ export default {
   registerUser,
   rollbackChart,
   uninstallTemplate,
+  updateUserInfo,
   updateUser,
   renameConfigMap,
   updateConfigMap,

+ 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=

+ 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=