Sfoglia il codice sorgente

Merge branch 'master' into advanced-routing

jusrhee 5 anni fa
parent
commit
ce38a262d4
62 ha cambiato i file con 1868 aggiunte e 656 eliminazioni
  1. 1 1
      README.md
  2. 56 11
      cli/cmd/run.go
  3. 6 2
      cmd/app/main.go
  4. 1 0
      dashboard/decs.d.ts
  5. 11 10
      dashboard/package-lock.json
  6. 1 2
      dashboard/package.json
  7. 123 0
      dashboard/src/assets/GoogleIcon.tsx
  8. 6 0
      dashboard/src/components/SaveButton.tsx
  9. 53 1
      dashboard/src/components/Selector.tsx
  10. 3 7
      dashboard/src/components/image-selector/ImageList.tsx
  11. 5 101
      dashboard/src/components/image-selector/ImageSelector.tsx
  12. 54 3
      dashboard/src/components/image-selector/TagList.tsx
  13. 97 14
      dashboard/src/main/CurrentError.tsx
  14. 125 60
      dashboard/src/main/auth/Login.tsx
  15. 111 62
      dashboard/src/main/auth/Register.tsx
  16. 10 0
      dashboard/src/main/home/Home.tsx
  17. 3 2
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  18. 29 6
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  19. 60 38
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  20. 91 58
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  21. 12 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ValuesYaml.tsx
  22. 8 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx
  23. 96 45
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  24. 1 4
      dashboard/src/main/home/launch/Launch.tsx
  25. 3 1
      dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx
  26. 8 3
      dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx
  27. 5 1
      dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx
  28. 199 0
      dashboard/src/main/home/modals/NamespaceModal.tsx
  29. 3 3
      dashboard/src/main/home/project-settings/InviteList.tsx
  30. 2 2
      dashboard/src/main/home/provisioner/ProvisionerLogs.tsx
  31. 23 0
      dashboard/src/shared/api.tsx
  32. 2 2
      docs/deploy/applications/deploying-from-docker-registry.md
  33. 1 0
      go.sum
  34. 26 4
      internal/adapter/gorm.go
  35. 19 6
      internal/config/config.go
  36. 4 0
      internal/forms/k8s.go
  37. 4 2
      internal/integrations/ci/actions/actions.go
  38. 6 5
      internal/integrations/ci/actions/steps.go
  39. 24 0
      internal/kubernetes/agent.go
  40. 10 0
      internal/kubernetes/config.go
  41. 1 0
      internal/models/integrations/oauth.go
  42. 1 0
      internal/models/user.go
  43. 13 0
      internal/oauth/config.go
  44. 21 10
      internal/registry/registry.go
  45. 9 0
      internal/repository/gorm/user.go
  46. 32 0
      internal/repository/gorm/user_test.go
  47. 15 0
      internal/repository/memory/user.go
  48. 1 0
      internal/repository/user.go
  49. 71 19
      server/api/api.go
  50. 1 15
      server/api/capability_handler.go
  51. 1 0
      server/api/deploy_handler.go
  52. 1 9
      server/api/dns_record_handler.go
  53. 1 0
      server/api/git_action_handler.go
  54. 113 0
      server/api/k8s_handler.go
  55. 213 0
      server/api/oauth_google_handler.go
  56. 12 130
      server/api/provision_handler.go
  57. 4 2
      server/api/release_handler.go
  58. 0 0
      server/middleware/auth.go
  59. 0 0
      server/middleware/json.go
  60. 0 0
      server/middleware/requestlog/handler.go
  61. 0 0
      server/middleware/requestlog/log_entry.go
  62. 56 13
      server/router/router.go

+ 1 - 1
README.md

@@ -45,7 +45,7 @@ For those who are familiar with Kubernetes and Helm:
 
 - Connect to existing Kubernetes clusters that are not provisioned by Porter
 - Visualize, deploy, and configure Helm charts via the GUI
-- User-generated [form overlays](https://docs.getporter.dev/docs/porter-templates) for managing `values.yaml`
+- User-generated [form overlays](https://github.com/porter-dev/porter-charts/blob/master/docs/form-yaml-reference.md) for managing `values.yaml`
 - In-depth view of releases, including revision histories and component graphs
 - Rollback/update of existing releases, including editing of raw `values.yaml`
 

+ 56 - 11
cli/cmd/run.go

@@ -56,25 +56,55 @@ func init() {
 func run(_ *api.AuthCheckResponse, client *api.Client, args []string) error {
 	color.New(color.FgGreen).Println("Running", strings.Join(args[1:], " "), "for release", args[0])
 
-	podNames, err := getPods(client, namespace, args[0])
+	podsSimple, err := getPods(client, namespace, args[0])
 
 	if err != nil {
 		return fmt.Errorf("Could not retrieve list of pods: %s", err.Error())
 	}
 
 	// if length of pods is 0, throw error
-	pod := ""
+	var selectedPod podSimple
 
-	if len(podNames) == 0 {
+	if len(podsSimple) == 0 {
 		return fmt.Errorf("At least one pod must exist in this deployment.")
-	} else if len(podNames) == 1 {
-		pod = podNames[0]
+	} else if len(podsSimple) == 1 {
+		selectedPod = podsSimple[0]
 	} else {
-		pod, err = utils.PromptSelect("Select the pod:", podNames)
+		podNames := make([]string, 0)
+
+		for _, podSimple := range podsSimple {
+			podNames = append(podNames, podSimple.Name)
+		}
+
+		selectedPodName, err := utils.PromptSelect("Select the pod:", podNames)
 
 		if err != nil {
 			return err
 		}
+
+		// find selected pod
+		for _, podSimple := range podsSimple {
+			if selectedPodName == podSimple.Name {
+				selectedPod = podSimple
+			}
+		}
+	}
+
+	var selectedContainerName string
+
+	// if the selected pod has multiple container, spawn selector
+	if len(selectedPod.ContainerNames) == 0 {
+		return fmt.Errorf("At least one pod must exist in this deployment.")
+	} else if len(selectedPod.ContainerNames) == 1 {
+		selectedContainerName = selectedPod.ContainerNames[0]
+	} else {
+		selectedContainer, err := utils.PromptSelect("Select the container:", selectedPod.ContainerNames)
+
+		if err != nil {
+			return err
+		}
+
+		selectedContainerName = selectedContainer
 	}
 
 	restConf, err := getRESTConfig(client)
@@ -83,7 +113,7 @@ func run(_ *api.AuthCheckResponse, client *api.Client, args []string) error {
 		return fmt.Errorf("Could not retrieve kube credentials: %s", err.Error())
 	}
 
-	return executeRun(restConf, namespace, pod, args[1:])
+	return executeRun(restConf, namespace, selectedPod.Name, selectedContainerName, args[1:])
 }
 
 func getRESTConfig(client *api.Client) (*rest.Config, error) {
@@ -120,7 +150,12 @@ func getRESTConfig(client *api.Client) (*rest.Config, error) {
 	return restConf, nil
 }
 
-func getPods(client *api.Client, namespace, releaseName string) ([]string, error) {
+type podSimple struct {
+	Name           string
+	ContainerNames []string
+}
+
+func getPods(client *api.Client, namespace, releaseName string) ([]podSimple, error) {
 	pID := getProjectID()
 	cID := getClusterID()
 
@@ -130,16 +165,25 @@ func getPods(client *api.Client, namespace, releaseName string) ([]string, error
 		return nil, err
 	}
 
-	res := make([]string, 0)
+	res := make([]podSimple, 0)
 
 	for _, pod := range resp {
-		res = append(res, pod.ObjectMeta.Name)
+		containerNames := make([]string, 0)
+
+		for _, container := range pod.Spec.Containers {
+			containerNames = append(containerNames, container.Name)
+		}
+
+		res = append(res, podSimple{
+			Name:           pod.ObjectMeta.Name,
+			ContainerNames: containerNames,
+		})
 	}
 
 	return res, nil
 }
 
-func executeRun(config *rest.Config, namespace, name string, args []string) error {
+func executeRun(config *rest.Config, namespace, name, container string, args []string) error {
 	restClient, err := rest.RESTClientFor(config)
 
 	if err != nil {
@@ -159,6 +203,7 @@ func executeRun(config *rest.Config, namespace, name string, args []string) erro
 	req.Param("stdin", "true")
 	req.Param("stdout", "true")
 	req.Param("tty", "true")
+	req.Param("container", container)
 
 	t := term.TTY{
 		In:  os.Stdin,

+ 6 - 2
cmd/app/main.go

@@ -102,15 +102,19 @@ func main() {
 		go prov.GlobalStreamListener(redis, *repo, errorChan)
 	}
 
-	a, _ := api.New(&api.AppConfig{
+	a, err := api.New(&api.AppConfig{
 		Logger:     logger,
 		Repository: repo,
 		ServerConf: appConf.Server,
 		RedisConf:  &appConf.Redis,
-		CapConf: 	appConf.Capabilities,
+		CapConf:    appConf.Capabilities,
 		DBConf:     appConf.Db,
 	})
 
+	if err != nil {
+		logger.Fatal().Err(err).Msg("")
+	}
+
 	appRouter := router.New(a)
 
 	address := fmt.Sprintf(":%d", appConf.Server.Port)

+ 1 - 0
dashboard/decs.d.ts

@@ -0,0 +1 @@
+declare module "js-yaml";

+ 11 - 10
dashboard/package-lock.json

@@ -556,11 +556,6 @@
       "integrity": "sha512-BnEyOcDE4H6bkg8m84xhdbkYoAoCg8sYERmAvE4Ff50U8jTfbmOinRdJpauBn1P9XsCCQgCLuSiyz3PM4WHYOA==",
       "dev": true
     },
-    "@types/js-yaml": {
-      "version": "3.12.5",
-      "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-3.12.5.tgz",
-      "integrity": "sha512-JCcp6J0GV66Y4ZMDAQCXot4xprYB+Zfd3meK9+INSJeVZwJmHAW30BBEEkPzXswMXuiyReUGOP3GxrADc9wPww=="
-    },
     "@types/json-schema": {
       "version": "7.0.6",
       "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.6.tgz",
@@ -4788,12 +4783,18 @@
       "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
     },
     "js-yaml": {
-      "version": "3.14.1",
-      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
-      "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+      "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
       "requires": {
-        "argparse": "^1.0.7",
-        "esprima": "^4.0.0"
+        "argparse": "^2.0.1"
+      },
+      "dependencies": {
+        "argparse": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+          "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
+        }
       }
     },
     "jsesc": {

+ 1 - 2
dashboard/package.json

@@ -6,7 +6,6 @@
     "@material-ui/core": "^4.11.3",
     "@types/d3-array": "^2.9.0",
     "@types/d3-time-format": "^3.0.0",
-    "@types/js-yaml": "^3.12.5",
     "@types/lodash": "^4.14.165",
     "@types/markdown-to-jsx": "^6.11.3",
     "@types/material-ui": "^0.21.8",
@@ -32,7 +31,7 @@
     "highlight.run": "^1.4.5",
     "ini": ">=1.3.6",
     "js-base64": "^3.6.0",
-    "js-yaml": "^3.14.0",
+    "js-yaml": "^4.1.0",
     "lodash": "^4.17.20",
     "markdown-to-jsx": "^7.0.1",
     "qs": "^6.9.4",

+ 123 - 0
dashboard/src/assets/GoogleIcon.tsx

@@ -0,0 +1,123 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+type PropsType = {};
+
+type StateType = {};
+
+export default class GHIcon extends Component<PropsType, StateType> {
+  render() {
+    return (
+      <Svg width="46px" height="46px" viewBox="0 0 46 46">
+        <title>btn_google_light_normal_ios</title>
+        <desc>Created with Sketch.</desc>
+        <defs>
+          <filter
+            x="-50%"
+            y="-50%"
+            width="200%"
+            height="200%"
+            filterUnits="objectBoundingBox"
+            id="filter-1"
+          >
+            <feOffset
+              dx="0"
+              dy="1"
+              in="SourceAlpha"
+              result="shadowOffsetOuter1"
+            ></feOffset>
+            <feGaussianBlur
+              stdDeviation="0.5"
+              in="shadowOffsetOuter1"
+              result="shadowBlurOuter1"
+            ></feGaussianBlur>
+            <feColorMatrix
+              values="0 0 0 0 0   0 0 0 0 0   0 0 0 0 0  0 0 0 0.168 0"
+              in="shadowBlurOuter1"
+              type="matrix"
+              result="shadowMatrixOuter1"
+            ></feColorMatrix>
+            <feOffset
+              dx="0"
+              dy="0"
+              in="SourceAlpha"
+              result="shadowOffsetOuter2"
+            ></feOffset>
+            <feGaussianBlur
+              stdDeviation="0.5"
+              in="shadowOffsetOuter2"
+              result="shadowBlurOuter2"
+            ></feGaussianBlur>
+            <feColorMatrix
+              values="0 0 0 0 0   0 0 0 0 0   0 0 0 0 0  0 0 0 0.084 0"
+              in="shadowBlurOuter2"
+              type="matrix"
+              result="shadowMatrixOuter2"
+            ></feColorMatrix>
+            <feMerge>
+              <feMergeNode in="shadowMatrixOuter1"></feMergeNode>
+              <feMergeNode in="shadowMatrixOuter2"></feMergeNode>
+              <feMergeNode in="SourceGraphic"></feMergeNode>
+            </feMerge>
+          </filter>
+          <rect id="path-2" x="0" y="0" width="40" height="40" rx="2"></rect>
+        </defs>
+        <g
+          id="Google-Button"
+          stroke="none"
+          stroke-width="1"
+          fill="none"
+          fill-rule="evenodd"
+        >
+          <g id="9-PATCH" transform="translate(-608.000000, -160.000000)"></g>
+          <g
+            id="btn_google_light_normal"
+            transform="translate(-1.000000, -1.000000)"
+          >
+            <g
+              id="button"
+              transform="translate(4.000000, 4.000000)"
+              filter="url(#filter-1)"
+            >
+              <g id="button-bg">
+                <use fill="#FFFFFF" fill-rule="evenodd"></use>
+                <use fill="none"></use>
+                <use fill="none"></use>
+                <use fill="none"></use>
+              </g>
+            </g>
+            <g
+              id="logo_googleg_48dp"
+              transform="translate(15.000000, 15.000000)"
+            >
+              <path
+                d="M17.64,9.20454545 C17.64,8.56636364 17.5827273,7.95272727 17.4763636,7.36363636 L9,7.36363636 L9,10.845 L13.8436364,10.845 C13.635,11.97 13.0009091,12.9231818 12.0477273,13.5613636 L12.0477273,15.8195455 L14.9563636,15.8195455 C16.6581818,14.2527273 17.64,11.9454545 17.64,9.20454545 L17.64,9.20454545 Z"
+                id="Shape"
+                fill="#4285F4"
+              ></path>
+              <path
+                d="M9,18 C11.43,18 13.4672727,17.1940909 14.9563636,15.8195455 L12.0477273,13.5613636 C11.2418182,14.1013636 10.2109091,14.4204545 9,14.4204545 C6.65590909,14.4204545 4.67181818,12.8372727 3.96409091,10.71 L0.957272727,10.71 L0.957272727,13.0418182 C2.43818182,15.9831818 5.48181818,18 9,18 L9,18 Z"
+                id="Shape"
+                fill="#34A853"
+              ></path>
+              <path
+                d="M3.96409091,10.71 C3.78409091,10.17 3.68181818,9.59318182 3.68181818,9 C3.68181818,8.40681818 3.78409091,7.83 3.96409091,7.29 L3.96409091,4.95818182 L0.957272727,4.95818182 C0.347727273,6.17318182 0,7.54772727 0,9 C0,10.4522727 0.347727273,11.8268182 0.957272727,13.0418182 L3.96409091,10.71 L3.96409091,10.71 Z"
+                id="Shape"
+                fill="#FBBC05"
+              ></path>
+              <path
+                d="M9,3.57954545 C10.3213636,3.57954545 11.5077273,4.03363636 12.4404545,4.92545455 L15.0218182,2.34409091 C13.4631818,0.891818182 11.4259091,0 9,0 C5.48181818,0 2.43818182,2.01681818 0.957272727,4.95818182 L3.96409091,7.29 C4.67181818,5.16272727 6.65590909,3.57954545 9,3.57954545 L9,3.57954545 Z"
+                id="Shape"
+                fill="#EA4335"
+              ></path>
+              <path d="M0,0 L18,0 L18,18 L0,18 L0,0 Z" id="Shape"></path>
+            </g>
+            <g id="handles_square"></g>
+          </g>
+        </g>
+      </Svg>
+    );
+  }
+}
+
+const Svg = styled.svg``;

+ 6 - 0
dashboard/src/components/SaveButton.tsx

@@ -81,6 +81,12 @@ const StatusWrapper = styled.div`
   font-size: 13px;
   color: #ffffff55;
   margin-right: 25px;
+  padding: 0 10px;
+
+  max-width: 500px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
 
   > i {
     font-size: 18px;

+ 53 - 1
dashboard/src/components/Selector.tsx

@@ -1,9 +1,12 @@
 import React, { Component } from "react";
 import styled from "styled-components";
+import { Context } from "shared/Context";
 
 type PropsType = {
   activeValue: string;
+  refreshOptions?: () => void;
   options: { value: string; label: string }[];
+  addButton?: boolean;
   setActiveValue: (x: string) => void;
   width: string;
   height?: string;
@@ -76,6 +79,21 @@ export default class Selector extends Component<PropsType, StateType> {
     }
   };
 
+  renderAddButton = () => {
+    if (this.props.addButton) {
+      return (
+        <NewOption
+          onClick={() => {
+            this.context.setCurrentModal("NamespaceModal");
+          }}
+        >
+          <Plus>+</Plus>
+          Add Namespace
+        </NewOption>
+      );
+    }
+  };
+
   renderDropdown = () => {
     if (this.state.expanded) {
       return (
@@ -91,6 +109,7 @@ export default class Selector extends Component<PropsType, StateType> {
         >
           {this.renderDropdownLabel()}
           {this.renderOptionList()}
+          {this.renderAddButton()}
         </Dropdown>
       );
     }
@@ -107,11 +126,17 @@ export default class Selector extends Component<PropsType, StateType> {
 
   render() {
     let { activeValue } = this.props;
+
     return (
       <StyledSelector width={this.props.width}>
         <MainSelector
           ref={this.parentRef}
-          onClick={() => this.setState({ expanded: !this.state.expanded })}
+          onClick={() => {
+            if (this.props.refreshOptions) {
+              this.props.refreshOptions();
+            }
+            this.setState({ expanded: !this.state.expanded });
+          }}
           expanded={this.state.expanded}
           width={this.props.width}
           height={this.props.height}
@@ -127,6 +152,13 @@ export default class Selector extends Component<PropsType, StateType> {
   }
 }
 
+Selector.contextType = Context;
+
+const Plus = styled.div`
+  margin-right: 10px;
+  font-size: 15px;
+`;
+
 const TextWrap = styled.div`
   white-space: nowrap;
   overflow: hidden;
@@ -141,6 +173,26 @@ const DropdownLabel = styled.div`
   margin: 10px 13px;
 `;
 
+const NewOption = styled.div`
+  display: flex;
+  width: 100%;
+  border-top: 1px solid #00000000;
+  border-bottom: 1px solid #ffffff00;
+  height: 37px;
+  font-size: 13px;
+  align-items: center;
+  padding-left: 15px;
+  cursor: pointer;
+  padding-right: 10px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+
+  :hover {
+    background: #ffffff22;
+  }
+`;
+
 const Option = styled.div`
   width: 100%;
   border-top: 1px solid #00000000;

+ 3 - 7
dashboard/src/components/image-selector/ImageList.tsx

@@ -18,6 +18,7 @@ type PropsType = {
   setSelectedImageUrl: (x: string) => void;
   setSelectedTag: (x: string) => void;
   setClickedImage: (x: ImageType) => void;
+  disableImageSelect?: boolean;
 };
 
 type StateType = {
@@ -162,11 +163,6 @@ export default class ImageList extends Component<PropsType, StateType> {
     }
   }
 
-  /*
-  <Highlight onClick={() => this.props.setCurrentView('integrations')}>
-    Link your registry.
-  </Highlight>
-  */
   renderImageList = () => {
     let { images, loading, error } = this.state;
 
@@ -206,8 +202,8 @@ export default class ImageList extends Component<PropsType, StateType> {
   };
 
   renderBackButton = () => {
-    let { setSelectedImageUrl } = this.props;
-    if (this.props.clickedImage) {
+    let { setSelectedImageUrl, clickedImage, disableImageSelect } = this.props;
+    if (clickedImage && !disableImageSelect) {
       return (
         <BackButton
           width="175px"

+ 5 - 101
dashboard/src/components/image-selector/ImageSelector.tsx

@@ -17,6 +17,7 @@ type PropsType = {
   setSelectedImageUrl: (x: string) => void;
   setSelectedTag: (x: string) => void;
   noTagSelection?: boolean;
+  disableImageSelect?: boolean;
 };
 
 type StateType = {
@@ -36,87 +37,6 @@ export default class ImageSelector extends Component<PropsType, StateType> {
     clickedImage: null as ImageType | null,
   };
 
-  // componentDidMount() {
-  //   const { currentProject, setCurrentError } = this.context;
-  //   let images = [] as ImageType[];
-  //   let errors = [] as number[];
-  //   api
-  //     .getProjectRegistries("<token>", {}, { id: currentProject.id })
-  //     .then(async (res) => {
-  //       let registries = res.data;
-  //       if (registries.length === 0) {
-  //         this.setState({ loading: false });
-  //       }
-
-  //       // Loop over connected image registries
-  //       registries.forEach(async (registry: any, i: number) => {
-  //         await new Promise((nextController: (res?: any) => void) => {
-  //           api
-  //             .getImageRepos(
-  //               "<token>",
-  //               {},
-  //               {
-  //                 project_id: currentProject.id,
-  //                 registry_id: registry.id,
-  //               }
-  //             )
-  //             .then((res) => {
-  //               res.data.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
-  //               // Loop over found image repositories
-  //               let newImg = res.data.map((img: any) => {
-  //                 if (this.props.selectedImageUrl === img.uri) {
-  //                   this.setState({
-  //                     clickedImage: {
-  //                       kind: registry.service,
-  //                       source: img.uri,
-  //                       name: img.name,
-  //                       registryId: registry.id,
-  //                     },
-  //                   });
-  //                 }
-  //                 return {
-  //                   kind: registry.service,
-  //                   source: img.uri,
-  //                   name: img.name,
-  //                   registryId: registry.id,
-  //                 };
-  //               });
-  //               images.push(...newImg);
-  //               errors.push(0);
-  //             })
-  //             .catch(() => errors.push(1))
-  //             .finally(() => {
-  //               if (i == registries.length - 1) {
-  //                 let error =
-  //                   errors.reduce((a, b) => {
-  //                     return a + b;
-  //                   }) == registries.length
-  //                     ? true
-  //                     : false;
-
-  //                 this.setState({
-  //                   images,
-  //                   loading: false,
-  //                   error,
-  //                 });
-  //               }
-
-  //               nextController();
-  //             });
-  //         });
-  //       });
-  //     })
-  //     .catch((err) => {
-  //       console.log(err);
-  //       this.setState({ error: true });
-  //     });
-  // }
-
-  /*
-  <Highlight onClick={() => this.props.setCurrentView('integrations')}>
-    Link your registry.
-  </Highlight>
-  */
   renderImageList = () => {
     let { images, loading, error } = this.state;
 
@@ -155,24 +75,6 @@ export default class ImageSelector extends Component<PropsType, StateType> {
     });
   };
 
-  renderBackButton = () => {
-    let { setSelectedImageUrl } = this.props;
-    if (this.state.clickedImage) {
-      return (
-        <BackButton
-          width="175px"
-          onClick={() => {
-            setSelectedImageUrl("");
-            this.setState({ clickedImage: null });
-          }}
-        >
-          <i className="material-icons">keyboard_backspace</i>
-          Select Image Repo
-        </BackButton>
-      );
-    }
-  };
-
   renderSelected = () => {
     let { selectedImageUrl, setSelectedImageUrl } = this.props;
     let { clickedImage } = this.state;
@@ -192,6 +94,7 @@ export default class ImageSelector extends Component<PropsType, StateType> {
       <Label>
         <img src={icon} />
         <Input
+          disabled={this.props.disableImageSelect}
           autoFocus={true}
           onClick={(e: any) => e.stopPropagation()}
           value={selectedImageUrl}
@@ -233,6 +136,7 @@ export default class ImageSelector extends Component<PropsType, StateType> {
 
         {this.state.isExpanded ? (
           <ImageList
+            disableImageSelect={this.props.disableImageSelect}
             selectedImageUrl={this.props.selectedImageUrl}
             selectedTag={this.props.selectedTag}
             clickedImage={this.state.clickedImage}
@@ -284,13 +188,13 @@ const BackButton = styled.div`
   }
 `;
 
-const Input = styled.input`
+const Input = styled.input<{ disabled: boolean }>`
   outline: 0;
   background: none;
   border: 0;
   font-size: 13px;
   width: calc(100% - 60px);
-  color: white;
+  color: ${(props) => (props.disabled ? "#aaaabb" : "#ffffff")};
 `;
 
 const ImageItem = styled.div`

+ 54 - 3
dashboard/src/components/image-selector/TagList.tsx

@@ -32,7 +32,8 @@ export default class TagList extends Component<PropsType, StateType> {
     currentTag: this.props.selectedTag,
   };
 
-  componentDidMount() {
+  refreshTagList = () => {
+    this.setState({ loading: true });
     const { currentProject } = this.context;
 
     let splits = this.props.selectedImageUrl.split("/");
@@ -55,6 +56,14 @@ export default class TagList extends Component<PropsType, StateType> {
         }
       )
       .then((res) => {
+        // Sort if timestamp is available
+        if (res.data.length > 0 && res.data[0].pushed_at) {
+          res.data.sort((a: any, b: any) => {
+            let d1 = new Date(a.pushed_at);
+            let d2 = new Date(b.pushed_at);
+            return d2.getTime() - d1.getTime();
+          });
+        }
         let tags = res.data.map((tag: any, i: number) => {
           return tag.tag;
         });
@@ -64,6 +73,10 @@ export default class TagList extends Component<PropsType, StateType> {
         console.log(err);
         this.setState({ loading: false, error: true });
       });
+  };
+
+  componentDidMount() {
+    this.refreshTagList();
   }
 
   setTag = (tag: string) => {
@@ -105,7 +118,12 @@ export default class TagList extends Component<PropsType, StateType> {
     return (
       <>
         <TagNameAlt>
-          <img src={info} /> Select Image Tag
+          <Label>
+            <img src={info} /> Select Image Tag
+          </Label>
+          <Refresh onClick={this.refreshTagList}>
+            <i className="material-icons">autorenew</i> Refresh
+          </Refresh>
         </TagNameAlt>
         <StyledTagList>{this.renderTagList()}</StyledTagList>
       </>
@@ -115,6 +133,36 @@ export default class TagList extends Component<PropsType, StateType> {
 
 TagList.contextType = Context;
 
+const Label = styled.div`
+  display: flex;
+  align-items: center;
+  > img {
+    width: 18px;
+    height: 18px;
+    margin-left: 12px;
+    margin-right: 12px;
+  }
+`;
+
+const Refresh = styled.div`
+  margin-right: 10px;
+  cursor: pointer;
+  color: #949eff;
+  display: flex;
+  align-items: center;
+  font-weight: 500;
+  border-radius: 3px;
+  padding: 2px 3px;
+  padding-right: 7px;
+  > i {
+    font-size: 17px;
+    margin-right: 6px;
+  }
+  :hover {
+    background: #ffffff11;
+  }
+`;
+
 const StyledTagList = styled.div`
   max-height: 175px;
   position: relative;
@@ -152,10 +200,13 @@ const TagName = styled.div`
 `;
 
 const TagNameAlt = styled(TagName)`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
   color: #ffffff55;
   cursor: default;
   :hover {
-    background: #ffffff11;
+    background: none;
     > i {
       background: none;
     }

+ 97 - 14
dashboard/src/main/CurrentError.tsx

@@ -29,12 +29,18 @@ export default class CurrentError extends Component<PropsType, StateType> {
     if (this.props.currentError) {
       if (!this.state.expanded) {
         return (
-          <StyledCurrentError onClick={() => this.setState({ expanded: true })}>
+          <StyledCurrentError>
             <ErrorText>Error: {this.props.currentError}</ErrorText>
+            <ExpandButton onClick={() => this.setState({ expanded: true })}>
+              <i className="material-icons">launch</i>
+            </ExpandButton>
             <CloseButton
               onClick={(e) => {
-                this.context.setCurrentError(null);
                 e.stopPropagation();
+
+                this.setState({ expanded: false }, () => {
+                  this.context.setCurrentError(null);
+                });
               }}
             >
               <CloseButtonImg src={close} />
@@ -44,12 +50,26 @@ export default class CurrentError extends Component<PropsType, StateType> {
       }
 
       return (
-        <ExpandedError onClick={() => this.setState({ expanded: false })}>
-          Error: {this.props.currentError}
-          <CloseButtonAlt onClick={() => this.context.setCurrentError(null)}>
-            <CloseButtonImg src={close} />
-          </CloseButtonAlt>
-        </ExpandedError>
+        <Overlay>
+          <ExpandedError>
+            Porter encountered an error. Full error log:
+            <CodeBlock>{this.props.currentError}</CodeBlock>
+            <ExpandButtonAlt onClick={() => this.setState({ expanded: false })}>
+              <i className="material-icons">remove</i>
+            </ExpandButtonAlt>
+            <CloseButtonAlt
+              onClick={(e) => {
+                e.stopPropagation();
+
+                this.setState({ expanded: false }, () => {
+                  this.context.setCurrentError(null);
+                });
+              }}
+            >
+              <CloseButtonImg src={close} />
+            </CloseButtonAlt>
+          </ExpandedError>
+        </Overlay>
       );
     }
 
@@ -66,7 +86,6 @@ const CloseButton = styled.div`
   width: 30px;
   height: 30px;
   border-radius: 50%;
-  margin-left: 10px;
   cursor: pointer;
   :hover {
     background-color: #ffffff11;
@@ -87,13 +106,13 @@ const ErrorText = styled.div`
   white-space: nowrap;
   overflow: hidden;
   text-overflow: ellipsis;
-  width: calc(100% - 50px);
+  width: calc(100% - 80px);
 `;
 
 const StyledCurrentError = styled.div`
   position: fixed;
   bottom: 22px;
-  width: 300px;
+  width: 310px;
   left: 20px;
   padding: 15px;
   padding-right: 0px;
@@ -127,10 +146,74 @@ const StyledCurrentError = styled.div`
   }
 `;
 
-const ExpandedError = styled(StyledCurrentError)`
-  width: 500px;
+const ExpandButton = styled(CloseButton)`
+  display: flex;
+  width: 30px;
+  height: 30px;
+  border-radius: 50%;
+  cursor: pointer;
+
+  :hover {
+    background-color: #ffffff11;
+  }
+
+  > i {
+    font-size: 16px;
+  }
+`;
+
+const ExpandButtonAlt = styled(ExpandButton)`
+  position: absolute;
+  top: 5px;
+  right: 34px;
+`;
+
+const Overlay = styled.div`
+  position: fixed;
+  margin: 0;
+  padding: 0;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background-color: rgba(0, 0, 0, 0.6);
+  z-index: 3;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const ExpandedError = styled.div`
+  position: fixed;
+  display: block;
+  width: 700px;
+  left: calc(50% - 350px);
   height: auto;
-  max-height: 300px;
+  max-height: 500px;
+  top: 50%;
+  transform: translateY(-50%);
   padding: 20px;
   overflow-y: auto;
+  background: #272731;
+  border: 1px solid #ffffff55;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  border-radius: 12px;
+`;
+
+const CodeBlock = styled.span`
+  display: block;
+  background-color: #1b1d26;
+  color: white;
+  border-radius: 5px;
+  font-family: monospace;
+  user-select: text;
+  max-height: 400px;
+  width: 90%;
+  margin-left: 5%;
+  margin-top: 20px;
+  overflow-x: hidden;
+  overflow-y: auto;
+  padding: 10px;
+  overflow-wrap: break-word;
 `;

+ 125 - 60
dashboard/src/main/auth/Login.tsx

@@ -2,6 +2,7 @@ import React, { ChangeEvent, Component } from "react";
 import styled from "styled-components";
 import logo from "assets/logo.png";
 import github from "assets/github-icon.png";
+import GoogleIcon from "assets/GoogleIcon";
 
 import api from "shared/api";
 import { emailRegex } from "shared/regex";
@@ -16,7 +17,10 @@ type StateType = {
   password: string;
   emailError: boolean;
   credentialError: boolean;
+  hasBasic: boolean;
   hasGithub: boolean;
+  hasGoogle: boolean;
+  hasResetPassword: boolean;
 };
 
 export default class Login extends Component<PropsType, StateType> {
@@ -25,7 +29,10 @@ export default class Login extends Component<PropsType, StateType> {
     password: "",
     emailError: false,
     credentialError: false,
+    hasBasic: true,
     hasGithub: true,
+    hasGoogle: false,
+    hasResetPassword: true,
   };
 
   handleKeyDown = (e: any) => {
@@ -43,7 +50,12 @@ export default class Login extends Component<PropsType, StateType> {
     api
       .getCapabilities("", {}, {})
       .then((res) => {
-        this.setState({ hasGithub: res.data?.github });
+        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));
   }
@@ -115,31 +127,104 @@ export default class Login extends Component<PropsType, StateType> {
     window.location.href = redirectUrl;
   };
 
+  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>
-          <OrWrapper>
-            <Line />
-            <Or>or</Or>
-          </OrWrapper>
-        </>
+        <OAuthButton onClick={this.githubRedirect}>
+          <IconWrapper>
+            <Icon src={github} />
+            Log in with GitHub
+          </IconWrapper>
+        </OAuthButton>
       );
     }
   };
 
-  render() {
-    let { email, password, credentialError, emailError } = this.state;
+  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>
+            <Input
+              type="email"
+              placeholder="Email"
+              value={email}
+              onChange={(e: ChangeEvent<HTMLInputElement>) =>
+                this.setState({
+                  email: e.target.value,
+                  emailError: false,
+                  credentialError: false,
+                })
+              }
+              valid={!credentialError && !emailError}
+            />
+            {this.renderEmailError()}
+          </InputWrapper>
+          <InputWrapper>
+            <Input
+              type="password"
+              placeholder="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>
+        <LoginPanel
+          hasBasic={this.state.hasBasic}
+          numOAuth={+this.state.hasGithub + +this.state.hasGoogle}
+        >
           <OverflowWrapper>
             <GradientBg />
           </OverflowWrapper>
@@ -147,47 +232,19 @@ export default class Login extends Component<PropsType, StateType> {
             <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 />
-            <InputWrapper>
-              <Input
-                type="email"
-                placeholder="Email"
-                value={email}
-                onChange={(e: ChangeEvent<HTMLInputElement>) =>
-                  this.setState({
-                    email: e.target.value,
-                    emailError: false,
-                    credentialError: false,
-                  })
-                }
-                valid={!credentialError && !emailError}
-              />
-              {this.renderEmailError()}
-            </InputWrapper>
-            <InputWrapper>
-              <Input
-                type="password"
-                placeholder="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>
-
-            <Helper>
-              <Link href="/register">Sign up</Link> |
-              <Link href="/password/reset">Forgot password?</Link>
-            </Helper>
+            {this.renderBasicSection()}
+            {this.renderHelper()}
           </FormWrapper>
         </LoginPanel>
-
         <Footer>
           © 2021 Porter Technologies Inc. •
           <Link
@@ -249,7 +306,12 @@ const IconWrapper = styled.div`
 
 const Icon = styled.img`
   height: 18px;
-  margin-right: 20px;
+  margin: 14px;
+`;
+
+const StyledGoogleIcon = styled(GoogleIcon)`
+  width: 38px;
+  height: 38px;
 `;
 
 const OAuthButton = styled.div`
@@ -264,6 +326,8 @@ const OAuthButton = styled.div`
   user-select: none;
   font-weight: 500;
   font-size: 13px;
+  margin: 10px 0;
+  overflow: hidden;
   :hover {
     background: #ffffffdd;
   }
@@ -392,11 +456,11 @@ const FormWrapper = styled.div`
 
 const GradientBg = styled.div`
   background: linear-gradient(#8ce1ff, #a59eff, #fba8ff);
-  width: 180%;
-  height: 180%;
+  width: 200%;
+  height: 200%;
   position: absolute;
-  top: -40%;
-  left: -40%;
+  top: -50%;
+  left: -50%;
   animation: flip 6s infinite linear;
   @keyframes flip {
     from {
@@ -410,7 +474,8 @@ const GradientBg = styled.div`
 
 const LoginPanel = styled.div`
   width: 330px;
-  height: 470px;
+  height: ${(props: { numOAuth: number; hasBasic: boolean }) =>
+    280 + +props.hasBasic * 150 + props.numOAuth * 50}px;
   background: white;
   margin-top: -20px;
   border-radius: 10px;

+ 111 - 62
dashboard/src/main/auth/Register.tsx

@@ -2,6 +2,7 @@ import React, { ChangeEvent, Component, useContext } from "react";
 import styled from "styled-components";
 import logo from "assets/logo.png";
 import github from "assets/github-icon.png";
+import GoogleIcon from "assets/GoogleIcon";
 
 import api from "shared/api";
 import { emailRegex } from "shared/regex";
@@ -18,6 +19,8 @@ type StateType = {
   emailError: boolean;
   confirmPasswordError: boolean;
   hasGithub: boolean;
+  hasGoogle: boolean;
+  hasBasic: boolean;
 };
 
 export default class Register extends Component<PropsType, StateType> {
@@ -27,7 +30,9 @@ export default class Register extends Component<PropsType, StateType> {
     confirmPassword: "",
     emailError: false,
     confirmPasswordError: false,
+    hasBasic: true,
     hasGithub: true,
+    hasGoogle: false,
   };
 
   handleKeyDown = (e: any) => {
@@ -41,7 +46,11 @@ export default class Register extends Component<PropsType, StateType> {
     api
       .getCapabilities("", {}, {})
       .then((res) => {
-        this.setState({ hasGithub: res.data?.github });
+        this.setState({
+          hasGithub: res.data?.github_login,
+          hasGoogle: res.data?.google_login,
+          hasBasic: res.data?.basic_login,
+        });
       })
       .catch((err) => console.log(err));
   }
@@ -55,6 +64,11 @@ export default class Register extends Component<PropsType, StateType> {
     window.location.href = redirectUrl;
   };
 
+  googleRedirect = () => {
+    let redirectUrl = `/api/oauth/login/google`;
+    window.location.href = redirectUrl;
+  };
+
   handleRegister = (): void => {
     let { email, password, confirmPassword } = this.state;
     let { authenticate } = this.props;
@@ -119,23 +133,30 @@ export default class Register extends Component<PropsType, StateType> {
   renderGithubSection = () => {
     if (this.state.hasGithub) {
       return (
-        <>
-          <OAuthButton onClick={this.githubRedirect}>
-            <IconWrapper>
-              <Icon src={github} />
-              Sign up with GitHub
-            </IconWrapper>
-          </OAuthButton>
-          <OrWrapper>
-            <Line />
-            <Or>or</Or>
-          </OrWrapper>
-        </>
+        <OAuthButton onClick={this.githubRedirect}>
+          <IconWrapper>
+            <Icon src={github} />
+            Sign up with GitHub
+          </IconWrapper>
+        </OAuthButton>
       );
     }
   };
 
-  render() {
+  renderGoogleSection = () => {
+    if (this.state.hasGoogle) {
+      return (
+        <OAuthButton onClick={this.googleRedirect}>
+          <IconWrapper>
+            <StyledGoogleIcon />
+            Sign up with Google
+          </IconWrapper>
+        </OAuthButton>
+      );
+    }
+  };
+
+  renderBasicSection = () => {
     let {
       email,
       password,
@@ -144,9 +165,61 @@ export default class Register extends Component<PropsType, StateType> {
       confirmPasswordError,
     } = this.state;
 
+    if (this.state.hasBasic) {
+      return (
+        <div>
+          <InputWrapper>
+            <Input
+              type="email"
+              placeholder="Email"
+              value={email}
+              onChange={(e: ChangeEvent<HTMLInputElement>) =>
+                this.setState({ email: e.target.value, emailError: false })
+              }
+              valid={!emailError}
+            />
+            {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>
+            <Input
+              type="password"
+              placeholder="Confirm Password"
+              value={confirmPassword}
+              onChange={(e: ChangeEvent<HTMLInputElement>) =>
+                this.setState({
+                  confirmPassword: e.target.value,
+                  confirmPasswordError: false,
+                })
+              }
+              valid={!confirmPasswordError}
+            />
+            {this.renderConfirmPasswordError()}
+          </InputWrapper>
+          <Button onClick={this.handleRegister}>Continue</Button>
+        </div>
+      );
+    }
+  };
+
+  render() {
     return (
       <StyledRegister>
-        <LoginPanel>
+        <LoginPanel
+          hasBasic={this.state.hasBasic}
+          numOAuth={+this.state.hasGithub + +this.state.hasGoogle}
+        >
           <OverflowWrapper>
             <GradientBg />
           </OverflowWrapper>
@@ -154,48 +227,16 @@ export default class Register extends Component<PropsType, StateType> {
             <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 />
-            <InputWrapper>
-              <Input
-                type="email"
-                placeholder="Email"
-                value={email}
-                onChange={(e: ChangeEvent<HTMLInputElement>) =>
-                  this.setState({ email: e.target.value, emailError: false })
-                }
-                valid={!emailError}
-              />
-              {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>
-              <Input
-                type="password"
-                placeholder="Confirm Password"
-                value={confirmPassword}
-                onChange={(e: ChangeEvent<HTMLInputElement>) =>
-                  this.setState({
-                    confirmPassword: e.target.value,
-                    confirmPasswordError: false,
-                  })
-                }
-                valid={!confirmPasswordError}
-              />
-              {this.renderConfirmPasswordError()}
-            </InputWrapper>
-            <Button onClick={this.handleRegister}>Continue</Button>
-
+            {this.renderBasicSection()}
             <Helper>
               Have an account?
               <Link href="/login">Sign in</Link>
@@ -263,7 +304,12 @@ const IconWrapper = styled.div`
 
 const Icon = styled.img`
   height: 18px;
-  margin-right: 20px;
+  margin: 14px;
+`;
+
+const StyledGoogleIcon = styled(GoogleIcon)`
+  width: 38px;
+  height: 38px;
 `;
 
 const OAuthButton = styled.div`
@@ -278,6 +324,8 @@ const OAuthButton = styled.div`
   user-select: none;
   font-weight: 500;
   font-size: 13px;
+  margin: 10px 0;
+  overflow: hidden;
   :hover {
     background: #ffffffdd;
   }
@@ -405,11 +453,11 @@ const FormWrapper = styled.div`
 
 const GradientBg = styled.div`
   background: linear-gradient(#8ce1ff, #a59eff, #fba8ff);
-  width: 180%;
-  height: 180%;
+  width: 200%;
+  height: 200%;
   position: absolute;
-  top: -40%;
-  left: -40%;
+  top: -50%;
+  left: -50%;
   animation: flip 6s infinite linear;
   @keyframes flip {
     from {
@@ -423,7 +471,8 @@ const GradientBg = styled.div`
 
 const LoginPanel = styled.div`
   width: 330px;
-  height: 500px;
+  height: ${(props: { numOAuth: number; hasBasic: boolean }) =>
+    270 + +props.hasBasic * 180 + props.numOAuth * 50}px;
   background: white;
   margin-top: -20px;
   border-radius: 10px;

+ 10 - 0
dashboard/src/main/home/Home.tsx

@@ -19,6 +19,7 @@ import IntegrationsInstructionsModal from "./modals/IntegrationsInstructionsModa
 import IntegrationsModal from "./modals/IntegrationsModal";
 import Modal from "./modals/Modal";
 import UpdateClusterModal from "./modals/UpdateClusterModal";
+import NamespaceModal from "./modals/NamespaceModal";
 import Navbar from "./navbar/Navbar";
 import NewProject from "./new-project/NewProject";
 import ProjectSettings from "./project-settings/ProjectSettings";
@@ -500,6 +501,15 @@ class Home extends Component<PropsType, StateType> {
             <IntegrationsInstructionsModal />
           </Modal>
         )}
+        {currentModal === "NamespaceModal" && (
+          <Modal
+            onRequestClose={() => setCurrentModal(null, null)}
+            width="600px"
+            height="220px"
+          >
+            <NamespaceModal />
+          </Modal>
+        )}
 
         {this.renderSidebar()}
 

+ 3 - 2
dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -107,9 +107,10 @@ export default class ChartList extends Component<PropsType, StateType> {
 
   setupWebsocket = (kind: string) => {
     let { currentCluster, currentProject } = this.context;
-    let protocol = process.env.NODE_ENV == "production" ? "wss" : "ws";
+    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
+
     let ws = new WebSocket(
-      `${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`
+      `${protocol}://${window.location.host}/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`
     );
     ws.onopen = () => {
       console.log("connected to websocket");

+ 29 - 6
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -152,9 +152,9 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
   setupWebsocket = (kind: string, chart: ChartType) => {
     let { currentCluster, currentProject } = this.context;
-    let protocol = process.env.NODE_ENV == "production" ? "wss" : "ws";
+    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
     let ws = new WebSocket(
-      `${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`
+      `${protocol}://${window.location.host}/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`
     );
     ws.onopen = () => {
       console.log("connected to websocket");
@@ -276,8 +276,19 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
         });
       })
       .catch((err) => {
-        console.log(err);
-        this.setState({ saveValuesStatus: "error" });
+        let parsedErr =
+          err?.response?.data?.errors && err.response.data.errors[0];
+
+        if (parsedErr) {
+          err = parsedErr;
+        }
+
+        this.setState({
+          saveValuesStatus: err,
+        });
+
+        setCurrentError(parsedErr);
+
         window.analytics.track("Failed to Upgrade Chart", {
           chart: this.state.currentChart.name,
           values: valuesYaml,
@@ -328,8 +339,20 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
         cb && cb();
       })
       .catch((err) => {
-        console.log(err);
-        this.setState({ saveValuesStatus: "error", loading: false });
+        let parsedErr =
+          err?.response?.data?.errors && err.response.data.errors[0];
+
+        if (parsedErr) {
+          err = parsedErr;
+        }
+
+        this.setState({
+          saveValuesStatus: err,
+          loading: false,
+        });
+
+        setCurrentError(parsedErr);
+
         window.analytics.track("Failed to Upgrade Chart", {
           chart: this.state.currentChart.name,
           values: valuesYaml,

+ 60 - 38
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -62,7 +62,7 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
   };
 
   // Retrieve full chart data (includes form and values)
-  getChartData = (chart: ChartType) => {
+  getChartData = (chart: ChartType, revision: number) => {
     let { currentProject } = this.context;
     let { currentCluster, currentChart } = this.props;
 
@@ -77,12 +77,15 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
         },
         {
           name: chart.name,
-          revision: chart.version,
+          revision: revision,
           id: currentProject.id,
         }
       )
       .then((res) => {
         let image = res.data?.config?.image?.repository;
+        let tag = res.data?.config?.image?.tag.toString();
+        let newestImage = tag ? image + ":" + tag : image;
+
         if (
           (image === "porterdev/hello-porter-job" ||
             image === "public.ecr.aws/o1j4x7p4/hello-porter-job") &&
@@ -93,7 +96,7 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
               currentChart: res.data,
               loading: false,
               imageIsPlaceholder: true,
-              newestImage: image,
+              newestImage: newestImage,
             },
             () => {
               this.updateTabs();
@@ -101,7 +104,11 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
           );
         } else {
           this.setState(
-            { currentChart: res.data, loading: false, newestImage: image },
+            {
+              currentChart: res.data,
+              loading: false,
+              newestImage: newestImage,
+            },
             () => {
               this.updateTabs();
             }
@@ -111,7 +118,8 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
       .catch(console.log);
   };
 
-  refreshChart = () => this.getChartData(this.state.currentChart);
+  refreshChart = (revision: number) =>
+    this.getChartData(this.state.currentChart, revision);
 
   mergeNewJob = (newJob: any) => {
     let jobs = this.state.jobs;
@@ -137,9 +145,9 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
     let chartVersion = `${chart.chart.metadata.name}-${chart.chart.metadata.version}`;
 
     let { currentCluster, currentProject } = this.context;
-    let protocol = process.env.NODE_ENV == "production" ? "wss" : "ws";
+    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
     let ws = new WebSocket(
-      `${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/k8s/job/status?cluster_id=${currentCluster.id}`
+      `${protocol}://${window.location.host}/api/projects/${currentProject.id}/k8s/job/status?cluster_id=${currentCluster.id}`
     );
     ws.onopen = () => {
       console.log("connected to websocket");
@@ -185,9 +193,9 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
     let releaseNamespace = chart.namespace;
 
     let { currentCluster, currentProject } = this.context;
-    let protocol = process.env.NODE_ENV == "production" ? "wss" : "ws";
+    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
     let ws = new WebSocket(
-      `${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/k8s/cronjob/status?cluster_id=${currentCluster.id}`
+      `${protocol}://${window.location.host}/api/projects/${currentProject.id}/k8s/cronjob/status?cluster_id=${currentCluster.id}`
     );
     ws.onopen = () => {
       console.log("connected to websocket");
@@ -254,15 +262,15 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
       let imageUrl = this.state.newestImage;
       let tag = null;
 
-      if (imageUrl.includes(":")) {
-        let splits = imageUrl.split(":");
-        imageUrl = splits[0];
-        tag = splits[1];
-      } else if (!tag) {
-        tag = "latest";
-      }
-
       if (imageUrl) {
+        if (imageUrl.includes(":")) {
+          let splits = imageUrl.split(":");
+          imageUrl = splits[0];
+          tag = splits[1].toString();
+        } else if (!tag) {
+          tag = "latest";
+        }
+
         _.set(values, "image.repository", imageUrl);
         _.set(values, "image.tag", tag);
       }
@@ -280,26 +288,29 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
       }
 
       let imageUrl = this.state.newestImage;
-      let tag = null;
-
-      if (imageUrl.includes(":")) {
-        let splits = imageUrl.split(":");
-        imageUrl = splits[0];
-        tag = splits[1];
-      } else if (!tag) {
-        tag = "latest";
-      }
+      let tag = null as string;
 
       if (imageUrl) {
+        if (imageUrl.includes(":")) {
+          let splits = imageUrl.split(":");
+          imageUrl = splits[0];
+          tag = splits[1].toString();
+        } else if (!tag) {
+          tag = "latest";
+        }
+
         _.set(values, "image.repository", imageUrl);
-        _.set(values, "image.tag", tag);
+        _.set(values, "image.tag", `${tag}`);
       }
 
       // Weave in preexisting values and convert to yaml
-      conf = yaml.dump({
-        ...(this.state.currentChart.config as Object),
-        ...values,
-      });
+      conf = yaml.dump(
+        {
+          ...(this.state.currentChart.config as Object),
+          ...values,
+        },
+        { forceQuotes: true }
+      );
     }
 
     api
@@ -318,12 +329,21 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
       )
       .then((res) => {
         this.setState({ saveValuesStatus: "successful" });
-        this.refreshChart();
+        this.refreshChart(0);
       })
       .catch((err) => {
-        console.log(err);
-        this.setState({ saveValuesStatus: "error" });
-        setCurrentError(JSON.stringify(err));
+        let parsedErr =
+          err?.response?.data?.errors && err.response.data.errors[0];
+
+        if (parsedErr) {
+          err = parsedErr;
+        }
+
+        this.setState({
+          saveValuesStatus: parsedErr,
+        });
+
+        setCurrentError(parsedErr);
       });
   };
 
@@ -401,8 +421,9 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
       case "settings":
         return (
           <SettingsSection
+            showSource={true}
             currentChart={this.state.currentChart}
-            refreshChart={this.refreshChart}
+            refreshChart={() => this.refreshChart(0)}
             setShowDeleteOverlay={(x: boolean) =>
               this.setState({ showDeleteOverlay: x })
             }
@@ -471,7 +492,7 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
       chart: currentChart.name,
     });
 
-    this.getChartData(currentChart);
+    this.getChartData(currentChart, currentChart.version);
     this.getJobs(currentChart);
     this.setupJobWebsocket(currentChart);
     this.setupCronJobWebsocket(currentChart);
@@ -535,7 +556,8 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
               </Title>
               <InfoWrapper>
                 <LastDeployed>
-                  Run {this.state.jobs.length} times <Dot>•</Dot>Last run
+                  Run {this.state.jobs.length} times <Dot>•</Dot>Last template
+                  update at
                   {" " + this.readableDate(chart.info.last_deployed)}
                 </LastDeployed>
               </InfoWrapper>

+ 91 - 58
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -22,6 +22,7 @@ type PropsType = {
   currentChart: ChartType;
   refreshChart: () => void;
   setShowDeleteOverlay: (x: boolean) => void;
+  showSource?: boolean;
 };
 
 type StateType = {
@@ -80,61 +81,6 @@ export default class SettingsSection extends Component<PropsType, StateType> {
       .catch(console.log);
   }
 
-  redeployWithNewImage = (img: string, tag: string) => {
-    this.setState({ saveValuesStatus: "loading" });
-    let { currentCluster, currentProject } = this.context;
-
-    // If tag is explicitly declared, parse tag
-    let imgSplits = img.split(":");
-    let parsedTag = null;
-    if (imgSplits.length > 1) {
-      img = imgSplits[0];
-      parsedTag = imgSplits[1];
-    }
-
-    let image = {
-      image: {
-        repository: img,
-        tag: parsedTag || tag,
-      },
-    };
-
-    let values = {};
-    let rawValues = this.props.currentChart.config;
-    for (let key in rawValues) {
-      _.set(values, key, rawValues[key]);
-    }
-
-    // Weave in preexisting values and convert to yaml
-    let valuesYaml = yaml.dump({
-      ...values,
-      ...image,
-    });
-
-    api
-      .upgradeChartValues(
-        "<token>",
-        {
-          namespace: this.props.currentChart.namespace,
-          storage: StorageType.Secret,
-          values: valuesYaml,
-        },
-        {
-          id: currentProject.id,
-          name: this.props.currentChart.name,
-          cluster_id: currentCluster.id,
-        }
-      )
-      .then((res) => {
-        this.setState({ saveValuesStatus: "successful" });
-        this.props.refreshChart();
-      })
-      .catch((err) => {
-        console.log(err);
-        this.setState({ saveValuesStatus: "error" });
-      });
-  };
-
   renderWebhookSection = () => {
     if (!this.props.currentChart?.form?.hasSource) {
       return;
@@ -144,6 +90,25 @@ export default class SettingsSection extends Component<PropsType, StateType> {
       let webhookText = `curl -X POST 'https://dashboard.getporter.dev/api/webhooks/deploy/${this.state.webhookToken}?commit=YOUR_COMMIT_HASH'`;
       return (
         <>
+          {this.props.showSource && (
+            <>
+              <Heading>Source Settings</Heading>
+              <Helper>Specify an image tag to use.</Helper>
+              <ImageSelector
+                selectedTag={this.state.selectedTag}
+                selectedImageUrl={this.state.selectedImageUrl}
+                setSelectedImageUrl={(x: string) =>
+                  this.setState({ selectedImageUrl: x })
+                }
+                setSelectedTag={(x: string) =>
+                  this.setState({ selectedTag: x })
+                }
+                forceExpanded={true}
+                disableImageSelect={true}
+              />
+              <Br />
+            </>
+          )}
           <Heading>Redeploy Webhook</Heading>
           <Helper>
             Programmatically deploy by calling this secret webhook.
@@ -166,10 +131,65 @@ export default class SettingsSection extends Component<PropsType, StateType> {
     }
   };
 
+  handleSubmit = () => {
+    let { currentCluster, setCurrentError, currentProject } = this.context;
+    this.setState({ saveValuesStatus: "loading" });
+
+    console.log(this.state.selectedImageUrl);
+
+    let values = {};
+    if (this.state.selectedTag) {
+      _.set(values, "image.repository", this.state.selectedImageUrl);
+      _.set(values, "image.tag", this.state.selectedTag);
+    }
+
+    // Weave in preexisting values and convert to yaml
+    let conf = yaml.dump(
+      {
+        ...(this.props.currentChart.config as Object),
+        ...values,
+      },
+      { forceQuotes: true }
+    );
+
+    api
+      .upgradeChartValues(
+        "<token>",
+        {
+          namespace: this.props.currentChart.namespace,
+          storage: StorageType.Secret,
+          values: conf,
+        },
+        {
+          id: currentProject.id,
+          name: this.props.currentChart.name,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then((res) => {
+        this.setState({ saveValuesStatus: "successful" });
+        this.props.refreshChart();
+      })
+      .catch((err) => {
+        let parsedErr =
+          err?.response?.data?.errors && err.response.data.errors[0];
+
+        if (parsedErr) {
+          err = parsedErr;
+        }
+
+        this.setState({
+          saveValuesStatus: parsedErr,
+        });
+
+        setCurrentError(parsedErr);
+      });
+  };
+
   render() {
     return (
       <Wrapper>
-        <StyledSettingsSection>
+        <StyledSettingsSection showSource={this.props.showSource}>
           {this.renderWebhookSection()}
           <Heading>Additional Settings</Heading>
           <Button
@@ -179,6 +199,14 @@ export default class SettingsSection extends Component<PropsType, StateType> {
             Delete {this.props.currentChart.name}
           </Button>
         </StyledSettingsSection>
+        {this.props.showSource && (
+          <SaveButton
+            text="Deploy"
+            status={this.state.saveValuesStatus}
+            onClick={this.handleSubmit}
+            makeFlush={true}
+          />
+        )}
       </Wrapper>
     );
   }
@@ -186,6 +214,11 @@ export default class SettingsSection extends Component<PropsType, StateType> {
 
 SettingsSection.contextType = Context;
 
+const Br = styled.div`
+  width: 100%;
+  height: 10px;
+`;
+
 const Button = styled.button`
   height: 35px;
   font-size: 13px;
@@ -270,15 +303,15 @@ const Wrapper = styled.div`
   height: 100%;
 `;
 
-const StyledSettingsSection = styled.div`
+const StyledSettingsSection = styled.div<{ showSource: boolean }>`
   width: 100%;
-  height: calc(100%);
   background: #ffffff11;
   padding: 0 35px;
   padding-bottom: 50px;
   position: relative;
   border-radius: 5px;
   overflow: auto;
+  height: ${(props) => (props.showSource ? "calc(100% - 55px)" : "100%")};
 `;
 
 const Holder = styled.div`

+ 12 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/ValuesYaml.tsx

@@ -67,8 +67,18 @@ export default class ValuesYaml extends Component<PropsType, StateType> {
         this.props.refreshChart();
       })
       .catch((err) => {
-        console.log(err);
-        this.setState({ saveValuesStatus: "error" });
+        let parsedErr =
+          err?.response?.data?.errors && err.response.data.errors[0];
+
+        if (parsedErr) {
+          err = parsedErr;
+        }
+
+        this.setState({
+          saveValuesStatus: parsedErr,
+        });
+
+        setCurrentError(parsedErr);
       });
   };
 

+ 8 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx

@@ -148,6 +148,7 @@ export default class JobResource extends Component<PropsType, StateType> {
         </ExpandConfigBar>
       );
     } else {
+      let tag = job.spec.template.spec.containers[0].image.split(":")[1];
       return (
         <>
           <ExpandConfigBar
@@ -164,6 +165,9 @@ export default class JobResource extends Component<PropsType, StateType> {
             ) : (
               <DarkMatter size="-18px" />
             )}
+            <Row>
+              Image Tag: <Command>{tag}</Command>
+            </Row>
             {!_.isEmpty(envObject) && (
               <>
                 <KeyValueArray
@@ -275,6 +279,10 @@ export default class JobResource extends Component<PropsType, StateType> {
 
 JobResource.contextType = Context;
 
+const Row = styled.div`
+  margin-top: 20px;
+`;
+
 const DarkMatter = styled.div<{ size?: string }>`
   width: 100%;
   margin-bottom: ${(props) => props.size || "-13px"};

+ 96 - 45
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx

@@ -14,6 +14,7 @@ type StateType = {
   logs: Anser.AnserJsonEntry[][];
   ws: any;
   scroll: boolean;
+  currentTab: string;
 };
 
 export default class Logs extends Component<PropsType, StateType> {
@@ -21,6 +22,7 @@ export default class Logs extends Component<PropsType, StateType> {
     logs: [] as Anser.AnserJsonEntry[][],
     ws: null as any,
     scroll: true,
+    currentTab: "Application",
   };
 
   ws = null as any;
@@ -92,48 +94,13 @@ export default class Logs extends Component<PropsType, StateType> {
     });
   };
 
-  getPodStatus = (status: any) => {
-    if (
-      status?.phase === "Pending" &&
-      status?.containerStatuses !== undefined
-    ) {
-      return status.containerStatuses[0].state.waiting.reason;
-    } else if (status?.phase === "Pending") {
-      return "pending";
-    }
-
-    if (status?.phase === "Succeeded") {
-      return "succeeded";
-    }
-
-    if (status?.phase === "Failed") {
-      return "failed";
-    }
-
-    if (status?.phase === "Running") {
-      let collatedStatus = "running";
-
-      status?.containerStatuses?.forEach((s: any) => {
-        if (s.state?.waiting) {
-          collatedStatus =
-            s.state?.waiting.reason === "CrashLoopBackOff"
-              ? "failed"
-              : "waiting";
-        } else if (s.state?.terminated) {
-          collatedStatus = "failed";
-        }
-      });
-      return collatedStatus;
-    }
-  };
-
   setupWebsocket = () => {
     let { currentCluster, currentProject } = this.context;
     let { selectedPod } = this.props;
     if (!selectedPod?.metadata?.name) return;
-    let protocol = process.env.NODE_ENV == "production" ? "wss" : "ws";
+    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
     this.ws = new WebSocket(
-      `${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/k8s/${selectedPod?.metadata?.namespace}/pod/${selectedPod?.metadata?.name}/logs?cluster_id=${currentCluster.id}&service_account_id=${currentCluster.service_account_id}`
+      `${protocol}://${window.location.host}/api/projects/${currentProject.id}/k8s/${selectedPod?.metadata?.namespace}/pod/${selectedPod?.metadata?.name}/logs?cluster_id=${currentCluster.id}&service_account_id=${currentCluster.service_account_id}`
     );
 
     this.ws.onopen = () => {};
@@ -157,23 +124,33 @@ export default class Logs extends Component<PropsType, StateType> {
   };
 
   refreshLogs = () => {
-    if (this.ws) {
+    let { selectedPod } = this.props;
+    if (this.ws && this.state.currentTab == "Application") {
       this.ws.close();
       this.ws = null;
       this.setState({ logs: [] });
       this.setupWebsocket();
     }
+    this.retrieveEvents(selectedPod);
   };
 
-  componentDidMount() {
-    let { selectedPod } = this.props;
-    let status = this.getPodStatus(selectedPod?.status);
-    if (status == "running" || status == "succeeded") {
-      this.setupWebsocket();
-      this.scrollToBottom(false);
-      return;
+  componentDidUpdate = (prevProps: any, prevState: any) => {
+    if (prevState.currentTab !== this.state.currentTab) {
+      let { selectedPod } = this.props;
+
+      this.setState({ logs: [] });
+
+      if (this.state.currentTab == "Application") {
+        this.setupWebsocket();
+        this.scrollToBottom(false);
+        return;
+      }
+
+      this.retrieveEvents(selectedPod);
     }
+  };
 
+  retrieveEvents = (selectedPod: any) => {
     api
       .getPodEvents(
         "<token>",
@@ -204,6 +181,18 @@ export default class Logs extends Component<PropsType, StateType> {
       .catch((err) => {
         console.log(err);
       });
+  };
+
+  componentDidMount() {
+    let { selectedPod } = this.props;
+
+    if (this.state.currentTab == "Application") {
+      this.setupWebsocket();
+      this.scrollToBottom(false);
+      return;
+    }
+
+    this.retrieveEvents(selectedPod);
   }
 
   componentWillUnmount() {
@@ -217,6 +206,24 @@ export default class Logs extends Component<PropsType, StateType> {
       return (
         <LogStreamAlt>
           <Wrapper ref={this.parentRef}>{this.renderLogs()}</Wrapper>
+          <LogTabs>
+            <Tab
+              onClick={() => {
+                this.setState({ currentTab: "Application" });
+              }}
+              clicked={this.state.currentTab == "Application"}
+            >
+              Application
+            </Tab>
+            <Tab
+              onClick={() => {
+                this.setState({ currentTab: "System" });
+              }}
+              clicked={this.state.currentTab == "System"}
+            >
+              System
+            </Tab>
+          </LogTabs>
         </LogStreamAlt>
       );
     }
@@ -224,6 +231,24 @@ export default class Logs extends Component<PropsType, StateType> {
     return (
       <LogStream>
         <Wrapper ref={this.parentRef}>{this.renderLogs()}</Wrapper>
+        <LogTabs>
+          <Tab
+            onClick={() => {
+              this.setState({ currentTab: "Application" });
+            }}
+            clicked={this.state.currentTab == "Application"}
+          >
+            Application
+          </Tab>
+          <Tab
+            onClick={() => {
+              this.setState({ currentTab: "System" });
+            }}
+            clicked={this.state.currentTab == "System"}
+          >
+            System
+          </Tab>
+        </LogTabs>
         <Options>
           <Scroll
             onClick={() => {
@@ -290,6 +315,22 @@ const Scroll = styled.div`
   }
 `;
 
+const Tab = styled.div`
+  background: ${(props: { clicked: boolean }) =>
+    props.clicked ? "#503559" : "#7c548a"};
+  padding: 0px 10px;
+  margin: 0px 7px 0px 0px;
+  align-items: center;
+  display: flex;
+  cursor: pointer;
+  height: 100%;
+  border-radius: 8px 8px 0px 0px;
+
+  :hover {
+    background: #503559;
+  }
+`;
+
 const Refresh = styled.div`
   display: flex;
   align-items: center;
@@ -309,6 +350,16 @@ const Refresh = styled.div`
   }
 `;
 
+const LogTabs = styled.div`
+  width: 100%;
+  height: 25px;
+  background: #202227;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: flex-end;
+`;
+
 const Options = styled.div`
   width: 100%;
   height: 25px;

+ 1 - 4
dashboard/src/main/home/launch/Launch.tsx

@@ -236,10 +236,7 @@ export default class Templates extends Component<PropsType, StateType> {
         <TemplatesWrapper>
           <TitleSection>
             <Title>Launch</Title>
-            <a
-              href="https://docs.getporter.dev/docs/porter-templates"
-              target="_blank"
-            >
+            <a href="https://docs.getporter.dev/docs/add-ons" target="_blank">
               <i className="material-icons">help_outline</i>
             </a>
           </TitleSection>

+ 3 - 1
dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx

@@ -498,7 +498,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
             To configure this chart through Porter,
             <Link
               target="_blank"
-              href="https://docs.getporter.dev/docs/porter-templates"
+              href="https://docs.getporter.dev/docs/add-ons"
             >
               refer to our docs
             </Link>
@@ -674,6 +674,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
   };
 
   render() {
+    console.log("RENDERING");
     let { name, icon } = this.props.currentTemplate;
     let { currentTemplate } = this.props;
 
@@ -743,6 +744,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
             setActiveValue={(namespace: string) =>
               this.setState({ selectedNamespace: namespace })
             }
+            addButton={true}
             options={this.state.namespaceOptions}
             width="250px"
             dropdownWidth="335px"

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

@@ -79,7 +79,7 @@ class LaunchFlow extends Component<PropsType, StateType> {
   };
 
   createGHAction = (chartName: string, chartNamespace: string, env?: any) => {
-    let { currentProject, currentCluster } = this.context;
+    let { currentProject, currentCluster, setCurrentError } = this.context;
     let {
       actionConfig,
       branch,
@@ -124,6 +124,8 @@ class LaunchFlow extends Component<PropsType, StateType> {
         this.setState({
           saveValuesStatus: `Could not create GitHub Action: ${err}`,
         });
+
+        setCurrentError(err);
       });
   };
 
@@ -184,7 +186,7 @@ class LaunchFlow extends Component<PropsType, StateType> {
           err = parsedErr;
         }
         this.setState({
-          saveValuesStatus: `Could not deploy template: ${err}`,
+          saveValuesStatus: parsedErr,
         });
         setCurrentError(err.response.data.errors[0]);
         window.analytics.track("Failed to Deploy Add-on", {
@@ -197,7 +199,7 @@ class LaunchFlow extends Component<PropsType, StateType> {
   };
 
   onSubmit = async (rawValues: any) => {
-    let { currentCluster, currentProject } = this.context;
+    let { currentCluster, currentProject, setCurrentError } = this.context;
     let {
       selectedNamespace,
       templateName,
@@ -283,6 +285,8 @@ class LaunchFlow extends Component<PropsType, StateType> {
               this.setState({
                 saveValuesStatus: `Could not create subdomain: ${err}`,
               });
+
+              setCurrentError(err);
             });
         });
 
@@ -338,6 +342,7 @@ class LaunchFlow extends Component<PropsType, StateType> {
         this.setState({
           saveValuesStatus: `Could not deploy template: ${err}`,
         });
+        setCurrentError(err);
       });
   };
 

+ 5 - 1
dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx

@@ -158,7 +158,7 @@ export default class SettingsPage extends Component<PropsType, StateType> {
             To configure this chart through Porter,
             <Link
               target="_blank"
-              href="https://docs.getporter.dev/docs/porter-templates"
+              href="https://github.com/porter-dev/porter-charts/blob/master/docs/form-yaml-reference.md"
             >
               refer to our docs
             </Link>
@@ -253,6 +253,10 @@ export default class SettingsPage extends Component<PropsType, StateType> {
             </NamespaceLabel>
             <Selector
               key={"namespace"}
+              refreshOptions={() => {
+                this.updateNamespaces(this.context.currentCluster.id);
+              }}
+              addButton={true}
               activeValue={selectedNamespace}
               setActiveValue={setSelectedNamespace}
               options={this.state.namespaceOptions}

+ 199 - 0
dashboard/src/main/home/modals/NamespaceModal.tsx

@@ -0,0 +1,199 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import close from "assets/close.png";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+import SaveButton from "components/SaveButton";
+import InputRow from "components/values-form/InputRow";
+
+type PropsType = {};
+
+type StateType = {
+  namespaceName: string;
+  status: string | null;
+};
+
+export default class NamespaceModal extends Component<PropsType, StateType> {
+  state = {
+    namespaceName: "",
+    status: null as string | null,
+  };
+
+  createNamespace = () => {
+    api
+      .createNamespace(
+        "<token>",
+        {
+          name: this.state.namespaceName,
+        },
+        {
+          id: this.context.currentProject.id,
+          cluster_id: this.context.currentCluster.id,
+        }
+      )
+      .then((res) => {
+        this.setState({ status: "successful" }, () => {
+          setTimeout(() => {
+            this.context.setCurrentModal(null, null);
+          }, 1000);
+        });
+      })
+      .catch((err) => {
+        this.setState({ status: "Could not create" });
+      });
+  };
+
+  render() {
+    return (
+      <StyledUpdateProjectModal>
+        <CloseButton
+          onClick={() => {
+            this.context.setCurrentModal(null, null);
+          }}
+        >
+          <CloseButtonImg src={close} />
+        </CloseButton>
+
+        <ModalTitle>Add Namespace</ModalTitle>
+        <Subtitle>Name</Subtitle>
+
+        <InputWrapper>
+          <DashboardIcon>
+            <i className="material-icons">space_dashboard</i>
+          </DashboardIcon>
+          <InputRow
+            type="string"
+            value={this.state.namespaceName}
+            setValue={(x: string) => this.setState({ namespaceName: x })}
+            placeholder="ex: porter-workers"
+            width="480px"
+          />
+        </InputWrapper>
+
+        {/* <Help
+          href="https://docs.getporter.dev/docs/deleting-dangling-resources"
+          target="_blank"
+        >
+          <i className="material-icons">help_outline</i> Help
+        </Help> */}
+
+        <SaveButton
+          text="Create Namespace"
+          color="#616FEEcc"
+          onClick={() => this.createNamespace()}
+          status={this.state.status}
+        />
+      </StyledUpdateProjectModal>
+    );
+  }
+}
+
+NamespaceModal.contextType = Context;
+
+const Help = styled.a`
+  position: absolute;
+  left: 31px;
+  bottom: 35px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff55;
+  font-size: 13px;
+  :hover {
+    color: #ffffff;
+  }
+
+  > i {
+    margin-right: 9px;
+    font-size: 16px;
+  }
+`;
+
+const DashboardIcon = styled.div`
+  width: 32px;
+  margin-top: 6px;
+  min-width: 25px;
+  height: 32px;
+  border-radius: 3px;
+  overflow: hidden;
+  position: relative;
+  margin-right: 15px;
+  font-weight: 400;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #676c7c;
+  border: 2px solid #8e94aa;
+  color: white;
+
+  > i {
+    font-size: 17px;
+  }
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Subtitle = styled.div`
+  margin-top: 23px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  margin-bottom: -10px;
+`;
+
+const ModalTitle = styled.div`
+  margin: 0px 0px 13px;
+  display: flex;
+  flex: 1;
+  font-family: "Assistant";
+  font-size: 18px;
+  color: #ffffff;
+  user-select: none;
+  font-weight: 700;
+  align-items: center;
+  position: relative;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  z-index: 1;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;
+
+const StyledUpdateProjectModal = styled.div`
+  width: 100%;
+  position: absolute;
+  left: 0;
+  top: 0;
+  height: 100%;
+  padding: 25px 30px;
+  overflow: hidden;
+  border-radius: 6px;
+  background: #202227;
+`;

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

@@ -28,7 +28,7 @@ export default class InviteList extends Component<PropsType, StateType> {
     invites: [] as InviteType[],
     email: "",
     invalidEmail: false,
-    isHTTPS: process.env.API_SERVER === "dashboard.getporter.dev",
+    isHTTPS: window.location.protocol === "https:",
   };
 
   componentDidMount() {
@@ -118,7 +118,7 @@ export default class InviteList extends Component<PropsType, StateType> {
     navigator.clipboard
       .writeText(
         `${this.state.isHTTPS ? "https://" : ""}${
-          process.env.API_SERVER
+          window.location.host
         }/api/projects/${currentProject.id}/invites/${
           this.state.invites[index].token
         }`
@@ -182,7 +182,7 @@ export default class InviteList extends Component<PropsType, StateType> {
                     disabled={true}
                     type="string"
                     value={`${this.state.isHTTPS ? "https://" : ""}${
-                      process.env.API_SERVER
+                      window.location.host
                     }/api/projects/${currentProject.id}/invites/${
                       this.state.invites[i].token
                     }`}

+ 2 - 2
dashboard/src/main/home/provisioner/ProvisionerLogs.tsx

@@ -192,9 +192,9 @@ class ProvisionerLogs extends Component<PropsType, StateType> {
 
     if (!selectedInfra) return;
 
-    let protocol = process.env.NODE_ENV == "production" ? "wss" : "ws";
+    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
     this.ws = new WebSocket(
-      `${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/provision/${selectedInfra.kind}/${selectedInfra.id}/logs`
+      `${protocol}://${window.location.host}/api/projects/${currentProject.id}/provision/${selectedInfra.kind}/${selectedInfra.id}/logs`
     );
 
     this.setupWebsocket();

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

@@ -798,6 +798,27 @@ const deleteConfigMap = baseApi<
   return `/api/projects/${pathParams.id}/k8s/configmap/delete`;
 });
 
+const createNamespace = baseApi<
+  {
+    name: string;
+  },
+  { id: number; cluster_id: number }
+>("POST", (pathParams) => {
+  let { id, cluster_id } = pathParams;
+  return `/api/projects/${id}/k8s/namespaces/create?cluster_id=${cluster_id}`;
+});
+
+const deleteNamespace = baseApi<
+  {
+    name: string;
+    cluster_id: number;
+  },
+  { id: number }
+>("DELETE", (pathParams) => {
+  let { id } = pathParams;
+  return `/api/projects/${id}/k8s/namespaces/delete`;
+});
+
 const stopJob = baseApi<
   {},
   { name: string; namespace: string; id: number; cluster_id: number }
@@ -820,6 +841,7 @@ export default {
   createGHAction,
   createGKE,
   createInvite,
+  createNamespace,
   createPasswordReset,
   createPasswordResetVerify,
   createPasswordResetFinalize,
@@ -829,6 +851,7 @@ export default {
   deleteConfigMap,
   deleteGitRepoIntegration,
   deleteInvite,
+  deleteNamespace,
   deletePod,
   deleteProject,
   deleteRegistryIntegration,

+ 2 - 2
docs/deploy/applications/deploying-from-docker-registry.md

@@ -21,6 +21,6 @@ Let's get started!
 
 5. To programmatically redeploy your service (for instance, from a CI pipeline), you will need to call your service's custom webhook. You can find your webhook by expanding your deployed service and going to the **Settings** tab.
 
-![Webhook](https://files.readme.io/23e217a-Screen_Shot_2021-03-18_at_11.29.16_AM.png "Screen Shot 2021-03-18 at 11.29.16 AM.png")
+![Webhook](https://user-images.githubusercontent.com/11699655/120046959-ac25c480-c013-11eb-8b2f-e6bfd704d7fc.png "webhook in the settings tab")
 
-Make sure to replace the `YOUR_COMMIT_HASH` and `IMAGE_REPOSITORY_URL` fields in the generated webhook.
+Make sure to replace the `YOUR_COMMIT_HASH` field with the tag of your Docker image.

+ 1 - 0
go.sum

@@ -1686,6 +1686,7 @@ k8s.io/apimachinery v0.20.0 h1:jjzbTJRXk0unNS71L7h3lxGDH/2HPxMPaQY+MjECKL8=
 k8s.io/apimachinery v0.20.0/go.mod h1:WlLqWAHZGg07AeltaI0MV5uk1Omp8xaN0JGLY6gkRpU=
 k8s.io/apimachinery v0.20.4/go.mod h1:WlLqWAHZGg07AeltaI0MV5uk1Omp8xaN0JGLY6gkRpU=
 k8s.io/apimachinery v0.21.0/go.mod h1:jbreFvJo3ov9rj7eWT7+sYiRx+qZuCYXwWT1bcDswPY=
+k8s.io/apimachinery v0.21.1 h1:Q6XuHGlj2xc+hlMCvqyYfbv3H7SRGn2c8NycxJquDVs=
 k8s.io/apiserver v0.18.8/go.mod h1:12u5FuGql8Cc497ORNj79rhPdiXQC4bf53X/skR/1YM=
 k8s.io/apiserver v0.20.4/go.mod h1:Mc80thBKOyy7tbvFtB4kJv1kbdD0eIH8k8vianJcbFM=
 k8s.io/cli-runtime v0.18.8 h1:ycmbN3hs7CfkJIYxJAOB10iW7BVPmXGXkfEyiV9NJ+k=

+ 26 - 4
internal/adapter/gorm.go

@@ -21,15 +21,35 @@ func New(conf *config.DBConf) (*gorm.DB, error) {
 		})
 	}
 
-	dsn := fmt.Sprintf(
-		"user=%s password=%s port=%d host=%s sslmode=disable",
+	// connect to default postgres instance first
+	baseDSN := fmt.Sprintf(
+		"user=%s password=%s port=%d host=%s",
 		conf.Username,
 		conf.Password,
 		conf.Port,
 		conf.Host,
 	)
 
-	res, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
+	if conf.ForceSSL {
+		baseDSN = baseDSN + " sslmode=require"
+	} else {
+		baseDSN = baseDSN + " sslmode=disable"
+	}
+
+	postgresDSN := baseDSN + " database=postgres"
+	targetDSN := baseDSN + " database=" + conf.DbName
+
+	defaultDB, err := gorm.Open(postgres.Open(postgresDSN), &gorm.Config{
+		FullSaveAssociations: true,
+	})
+
+	// attempt to create the database
+	if conf.DbName != "" {
+		defaultDB.Exec(fmt.Sprintf("CREATE DATABASE %s;", conf.DbName))
+	}
+
+	// open the database connection
+	res, err := gorm.Open(postgres.Open(targetDSN), &gorm.Config{
 		FullSaveAssociations: true,
 	})
 
@@ -40,7 +60,9 @@ func New(conf *config.DBConf) (*gorm.DB, error) {
 	if err != nil {
 		for {
 			time.Sleep(timeout)
-			res, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
+			res, err = gorm.Open(postgres.Open(targetDSN), &gorm.Config{
+				FullSaveAssociations: true,
+			})
 
 			if retryCount > 3 {
 				return nil, err

+ 19 - 6
internal/config/config.go

@@ -9,11 +9,11 @@ import (
 
 // Conf is the configuration for the Go server
 type Conf struct {
-	Debug  bool `env:"DEBUG,default=false"`
-	Server ServerConf
-	Db     DBConf
-	K8s    K8sConf
-	Redis  RedisConf
+	Debug        bool `env:"DEBUG,default=false"`
+	Server       ServerConf
+	Db           DBConf
+	K8s          K8sConf
+	Redis        RedisConf
 	Capabilities CapConf
 }
 
@@ -35,8 +35,15 @@ type ServerConf struct {
 	DefaultApplicationHelmRepoURL string `env:"HELM_APP_REPO_URL,default=https://charts.dev.getporter.dev"`
 	DefaultAddonHelmRepoURL       string `env:"HELM_ADD_ON_REPO_URL,default=https://chart-addons.dev.getporter.dev"`
 
+	BasicLoginEnabled bool `env:"BASIC_LOGIN_ENABLED,default=true"`
+
 	GithubClientID     string `env:"GITHUB_CLIENT_ID"`
 	GithubClientSecret string `env:"GITHUB_CLIENT_SECRET"`
+	GithubLoginEnabled bool   `env:"GITHUB_LOGIN_ENABLED,default=true"`
+
+	GoogleClientID         string `env:"GOOGLE_CLIENT_ID"`
+	GoogleClientSecret     string `env:"GOOGLE_CLIENT_SECRET"`
+	GoogleRestrictedDomain string `env:"GOOGLE_RESTRICTED_DOMAIN"`
 
 	SendgridAPIKey                  string `env:"SENDGRID_API_KEY"`
 	SendgridPWResetTemplateID       string `env:"SENDGRID_PW_RESET_TEMPLATE_ID"`
@@ -62,6 +69,7 @@ type DBConf struct {
 	Username string `env:"DB_USER,default=porter"`
 	Password string `env:"DB_PASS,default=porter"`
 	DbName   string `env:"DB_NAME,default=porter"`
+	ForceSSL bool   `env:"DB_FORCE_SSL,default=false"`
 
 	SQLLite     bool   `env:"SQL_LITE,default=false"`
 	SQLLitePath string `env:"SQL_LITE_PATH,default=/porter/porter.db"`
@@ -74,7 +82,8 @@ type K8sConf struct {
 
 type CapConf struct {
 	Provisioner bool `env:"PROVISIONER_ENABLED,default=true"`
-	Github bool `env:"GITHUB_ENABLED,default=true"`
+	Github      bool `env:"GITHUB_ENABLED,default=true"`
+	Google      bool
 }
 
 // FromEnv generates a configuration from environment variables
@@ -85,5 +94,9 @@ func FromEnv() *Conf {
 		log.Fatalf("Failed to decode server conf: %s", err)
 	}
 
+	if c.Server.GoogleClientID != "" && c.Server.GoogleClientSecret != "" {
+		c.Capabilities.Google = true
+	}
+
 	return &c
 }

+ 4 - 0
internal/forms/k8s.go

@@ -44,3 +44,7 @@ type ConfigMapForm struct {
 	EnvVariables       map[string]string `json:"variables"`
 	SecretEnvVariables map[string]string `json:"secret_variables"`
 }
+
+type NamespaceForm struct {
+	Name string `json:"name" form:"required"`
+}

+ 4 - 2
internal/integrations/ci/actions/actions.go

@@ -17,6 +17,8 @@ import (
 )
 
 type GithubActions struct {
+	ServerURL string
+
 	GitIntegration *models.GitRepo
 	GitRepoName    string
 	GitRepoOwner   string
@@ -157,7 +159,7 @@ func (g *GithubActions) GetGithubActionYAML() ([]byte, error) {
 	gaSteps := []GithubActionYAMLStep{
 		getCheckoutCodeStep(),
 		getDownloadPorterStep(),
-		getConfigurePorterStep(g.getPorterTokenSecretName()),
+		getConfigurePorterStep(g.ServerURL, g.getPorterTokenSecretName()),
 	}
 
 	if g.DockerFilePath == "" {
@@ -166,7 +168,7 @@ func (g *GithubActions) GetGithubActionYAML() ([]byte, error) {
 		gaSteps = append(gaSteps, getDockerBuildPushStep(g.getBuildEnvSecretName(), g.DockerFilePath, g.ImageRepoURL))
 	}
 
-	gaSteps = append(gaSteps, deployPorterWebhookStep(g.getWebhookSecretName(), g.ImageRepoURL))
+	gaSteps = append(gaSteps, deployPorterWebhookStep(g.ServerURL, g.getWebhookSecretName()))
 
 	branch := g.GitBranch
 

+ 6 - 5
internal/integrations/ci/actions/steps.go

@@ -31,15 +31,16 @@ func getDownloadPorterStep() GithubActionYAMLStep {
 }
 
 const configure string = `
+sudo porter config set-host %s
 sudo porter auth login --token ${{secrets.%s}}
 sudo porter docker configure
 `
 
-func getConfigurePorterStep(porterTokenSecretName string) GithubActionYAMLStep {
+func getConfigurePorterStep(serverURL, porterTokenSecretName string) GithubActionYAMLStep {
 	return GithubActionYAMLStep{
 		Name: "Configure Porter",
 		ID:   "configure_porter",
-		Run:  fmt.Sprintf(configure, porterTokenSecretName),
+		Run:  fmt.Sprintf(configure, serverURL, porterTokenSecretName),
 	}
 }
 
@@ -77,13 +78,13 @@ func getBuildPackPushStep(envSecretName, folderPath, repoURL string) GithubActio
 }
 
 const deployPorter string = `
-curl -X POST "https://dashboard.getporter.dev/api/webhooks/deploy/${{secrets.%s}}?commit=$(git rev-parse --short HEAD)&repository=%s"
+curl -X POST "%s/api/webhooks/deploy/${{secrets.%s}}?commit=$(git rev-parse --short HEAD)"
 `
 
-func deployPorterWebhookStep(webhookTokenSecretName, repoURL string) GithubActionYAMLStep {
+func deployPorterWebhookStep(serverURL, webhookTokenSecretName string) GithubActionYAMLStep {
 	return GithubActionYAMLStep{
 		Name: "Deploy on Porter",
 		ID:   "deploy_porter",
-		Run:  fmt.Sprintf(deployPorter, webhookTokenSecretName, repoURL),
+		Run:  fmt.Sprintf(deployPorter, serverURL, webhookTokenSecretName),
 	}
 }

+ 24 - 0
internal/kubernetes/agent.go

@@ -233,6 +233,30 @@ func (a *Agent) ListNamespaces() (*v1.NamespaceList, error) {
 	)
 }
 
+// CreateNamespace creates a namespace with the given name.
+func (a *Agent) CreateNamespace(name string) (*v1.Namespace, error) {
+	namespace := v1.Namespace{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: name,
+		},
+	}
+
+	return a.Clientset.CoreV1().Namespaces().Create(
+		context.TODO(),
+		&namespace,
+		metav1.CreateOptions{},
+	)
+}
+
+// DeleteNamespace deletes the namespace given the name.
+func (a *Agent) DeleteNamespace(name string) error {
+	return a.Clientset.CoreV1().Namespaces().Delete(
+		context.TODO(),
+		name,
+		metav1.DeleteOptions{},
+	)
+}
+
 // ListJobsByLabel lists jobs in a namespace matching a label
 type Label struct {
 	Key string

+ 10 - 0
internal/kubernetes/config.go

@@ -66,6 +66,16 @@ func GetAgentOutOfClusterConfig(conf *OutOfClusterConfig) (*Agent, error) {
 	return &Agent{conf, clientset}, nil
 }
 
+// IsInCluster returns true if the process is running in a Kubernetes cluster,
+// false otherwise
+func IsInCluster() bool {
+	_, err := rest.InClusterConfig()
+
+	// If the error is not nil, it is either rest.ErrNotInCluster or the in-cluster
+	// config cannot be read. In either case, in-cluster operations are not supported.
+	return err == nil
+}
+
 // GetAgentInClusterConfig uses the service account that kubernetes
 // gives to pods to connect
 func GetAgentInClusterConfig() (*Agent, error) {

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

@@ -11,6 +11,7 @@ type OAuthIntegrationClient string
 const (
 	OAuthGithub       OAuthIntegrationClient = "github"
 	OAuthDigitalOcean OAuthIntegrationClient = "do"
+	OAuthGoogle       OAuthIntegrationClient = "google"
 )
 
 // OAuthIntegration is an auth mechanism that uses oauth

+ 1 - 0
internal/models/user.go

@@ -14,6 +14,7 @@ type User struct {
 
 	// The github user id used for login (optional)
 	GithubUserID int64
+	GoogleUserID string
 }
 
 // UserExternal represents the User type that is sent over REST

+ 13 - 0
internal/oauth/config.go

@@ -44,6 +44,19 @@ func NewDigitalOceanClient(cfg *Config) *oauth2.Config {
 	}
 }
 
+func NewGoogleClient(cfg *Config) *oauth2.Config {
+	return &oauth2.Config{
+		ClientID:     cfg.ClientID,
+		ClientSecret: cfg.ClientSecret,
+		Endpoint: oauth2.Endpoint{
+			AuthURL:  "https://accounts.google.com/o/oauth2/v2/auth",
+			TokenURL: "https://oauth2.googleapis.com/token",
+		},
+		RedirectURL: cfg.BaseURL + "/api/oauth/google/callback",
+		Scopes:      cfg.Scopes,
+	}
+}
+
 func CreateRandomState() string {
 	b := make([]byte, 16)
 	rand.Read(b)

+ 21 - 10
internal/registry/registry.go

@@ -52,6 +52,9 @@ type Image struct {
 
 	// The name of the repository associated with the image.
 	RepositoryName string `json:"repository_name"`
+
+	// When the image was pushed
+	PushedAt *time.Time `json:"pushed_at"`
 }
 
 // ListRepositories lists the repositories for a registry
@@ -479,18 +482,26 @@ func (r *Registry) listECRImages(repoName string, repo repository.Repository) ([
 		return nil, err
 	}
 
+	describeResp, err := svc.DescribeImages(&ecr.DescribeImagesInput{
+		RepositoryName: &repoName,
+		ImageIds:       resp.ImageIds,
+	})
+
+	if err != nil {
+		return nil, err
+	}
+
 	res := make([]*Image, 0)
 
-	for _, img := range resp.ImageIds {
-		if img.ImageTag == nil {
-			continue
+	for _, img := range describeResp.ImageDetails {
+		for _, tag := range img.ImageTags {
+			res = append(res, &Image{
+				Digest:         *img.ImageDigest,
+				Tag:            *tag,
+				RepositoryName: repoName,
+				PushedAt:       img.ImagePushedAt,
+			})
 		}
-
-		res = append(res, &Image{
-			Digest:         *img.ImageDigest,
-			Tag:            *img.ImageTag,
-			RepositoryName: repoName,
-		})
 	}
 
 	return res, nil
@@ -909,4 +920,4 @@ func (r *Registry) getPrivateRegistryDockerConfigFile(
 
 func generateAuthToken(username, password string) string {
 	return base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
-}
+}

+ 9 - 0
internal/repository/gorm/user.go

@@ -53,6 +53,15 @@ func (repo *UserRepository) ReadUserByGithubUserID(id int64) (*models.User, erro
 	return user, nil
 }
 
+// ReadUserByGoogleUserID finds a single user based on their google user id
+func (repo *UserRepository) ReadUserByGoogleUserID(id string) (*models.User, error) {
+	user := &models.User{}
+	if err := repo.db.Where("google_user_id = ?", id).First(&user).Error; err != nil {
+		return nil, err
+	}
+	return user, nil
+}
+
 // UpdateUser modifies an existing User in the database
 func (repo *UserRepository) UpdateUser(user *models.User) (*models.User, error) {
 	if err := repo.db.Save(user).Error; err != nil {

+ 32 - 0
internal/repository/gorm/user_test.go

@@ -38,3 +38,35 @@ func TestReadUserByGithubUserID(t *testing.T) {
 		t.Error(diff)
 	}
 }
+
+func TestReadUserByGoogleUserID(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_read_user_google.db",
+	}
+
+	setupTestEnv(tester, t)
+	defer cleanup(tester, t)
+
+	user := &models.User{
+		Email:        "test@test.it",
+		Password:     "fake",
+		GoogleUserID: "alsdkfjsldaf",
+	}
+
+	user, err := tester.repo.User.CreateUser(user)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	readUser, err := tester.repo.User.ReadUserByGoogleUserID("alsdkfjsldaf")
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if diff := deep.Equal(user, readUser); diff != nil {
+		t.Errorf("users not equal:")
+		t.Error(diff)
+	}
+}

+ 15 - 0
internal/repository/memory/user.go

@@ -86,6 +86,21 @@ func (repo *UserRepository) ReadUserByGithubUserID(id int64) (*models.User, erro
 	return nil, gorm.ErrRecordNotFound
 }
 
+// ReadUserByGoogleUserID finds a single user based on their github id field
+func (repo *UserRepository) ReadUserByGoogleUserID(id string) (*models.User, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	for _, u := range repo.users {
+		if u.GoogleUserID == id && id != "" {
+			return u, nil
+		}
+	}
+
+	return nil, gorm.ErrRecordNotFound
+}
+
 // UpdateUser modifies an existing User in the database
 func (repo *UserRepository) UpdateUser(user *models.User) (*models.User, error) {
 	if !repo.canQuery {

+ 1 - 0
internal/repository/user.go

@@ -14,6 +14,7 @@ type UserRepository interface {
 	ReadUser(id uint) (*models.User, error)
 	ReadUserByEmail(email string) (*models.User, error)
 	ReadUserByGithubUserID(id int64) (*models.User, error)
+	ReadUserByGoogleUserID(id string) (*models.User, error)
 	UpdateUser(user *models.User) (*models.User, error)
 	DeleteUser(user *models.User) (*models.User, error)
 }

+ 71 - 19
server/api/api.go

@@ -21,8 +21,8 @@ import (
 	"github.com/porter-dev/porter/internal/repository"
 	memory "github.com/porter-dev/porter/internal/repository/memory"
 	"github.com/porter-dev/porter/internal/validator"
-	"helm.sh/helm/v3/pkg/storage"
 	segment "gopkg.in/segmentio/analytics-go.v3"
+	"helm.sh/helm/v3/pkg/storage"
 
 	"github.com/porter-dev/porter/internal/config"
 )
@@ -42,7 +42,7 @@ type AppConfig struct {
 	ServerConf config.ServerConf
 	RedisConf  *config.RedisConf
 	DBConf     config.DBConf
-	CapConf config.CapConf
+	CapConf    config.CapConf
 
 	// TestAgents if API is in testing mode
 	TestAgents *TestAgents
@@ -66,6 +66,9 @@ type App struct {
 	// agents exposed for testing
 	TestAgents *TestAgents
 
+	// An in-cluster agent if service is running in cluster
+	InClusterAgent *kubernetes.Agent
+
 	// redis client for redis connection
 	RedisConf *config.RedisConf
 
@@ -73,20 +76,31 @@ type App struct {
 	DBConf config.DBConf
 
 	// config for capabilities
-	CapConf config.CapConf
+	Capabilities *AppCapabilities
 
 	// oauth-specific clients
 	GithubUserConf    *oauth2.Config
 	GithubProjectConf *oauth2.Config
 	DOConf            *oauth2.Config
+	GoogleUserConf    *oauth2.Config
 
-	db         *gorm.DB
-	validator  *vr.Validate
-	translator *ut.Translator
-	tokenConf  *token.TokenGeneratorConf
+	db            *gorm.DB
+	validator     *vr.Validate
+	translator    *ut.Translator
+	tokenConf     *token.TokenGeneratorConf
 	segmentClient *segment.Client
 }
 
+type AppCapabilities struct {
+	Provisioning bool `json:"provisioner"`
+	Github       bool `json:"github"`
+	BasicLogin   bool `json:"basic_login"`
+	GithubLogin  bool `json:"github_login"`
+	GoogleLogin  bool `json:"google_login"`
+	Email        bool `json:"email"`
+	Analytics    bool `json:"analytics"`
+}
+
 // New returns a new App instance
 func New(conf *AppConfig) (*App, error) {
 	// create a new validator and translator
@@ -101,16 +115,16 @@ func New(conf *AppConfig) (*App, error) {
 	}
 
 	app := &App{
-		Logger:     conf.Logger,
-		Repo:       conf.Repository,
-		ServerConf: conf.ServerConf,
-		RedisConf:  conf.RedisConf,
-		DBConf:     conf.DBConf,
-		CapConf: 	conf.CapConf,
-		TestAgents: conf.TestAgents,
-		db:         conf.DB,
-		validator:  validator,
-		translator: &translator,
+		Logger:       conf.Logger,
+		Repo:         conf.Repository,
+		ServerConf:   conf.ServerConf,
+		RedisConf:    conf.RedisConf,
+		DBConf:       conf.DBConf,
+		TestAgents:   conf.TestAgents,
+		Capabilities: &AppCapabilities{},
+		db:           conf.DB,
+		validator:    validator,
+		translator:   &translator,
 	}
 
 	// if repository not specified, default to in-memory
@@ -127,8 +141,25 @@ func New(conf *AppConfig) (*App, error) {
 
 	app.Store = store
 
+	// if application is running in-cluster, set provisioning capabilities
+	if kubernetes.IsInCluster() {
+		app.Capabilities.Provisioning = true
+
+		agent, err := kubernetes.GetAgentInClusterConfig()
+
+		if err != nil {
+			return nil, fmt.Errorf("could not get in-cluster agent: %v", err)
+		}
+
+		app.InClusterAgent = agent
+	}
+
+	sc := conf.ServerConf
+
 	// if server config contains OAuth client info, create clients
-	if sc := conf.ServerConf; sc.GithubClientID != "" && sc.GithubClientSecret != "" {
+	if sc.GithubClientID != "" && sc.GithubClientSecret != "" {
+		app.Capabilities.Github = true
+
 		app.GithubUserConf = oauth.NewGithubClient(&oauth.Config{
 			ClientID:     sc.GithubClientID,
 			ClientSecret: sc.GithubClientSecret,
@@ -142,9 +173,26 @@ func New(conf *AppConfig) (*App, error) {
 			Scopes:       []string{"repo", "read:user", "workflow"},
 			BaseURL:      sc.ServerURL,
 		})
+
+		app.Capabilities.GithubLogin = sc.GithubLoginEnabled
+	}
+
+	if sc.GoogleClientID != "" && sc.GoogleClientSecret != "" {
+		app.Capabilities.GoogleLogin = true
+
+		app.GoogleUserConf = oauth.NewGoogleClient(&oauth.Config{
+			ClientID:     sc.GoogleClientID,
+			ClientSecret: sc.GoogleClientSecret,
+			Scopes: []string{
+				"openid",
+				"profile",
+				"email",
+			},
+			BaseURL: sc.ServerURL,
+		})
 	}
 
-	if sc := conf.ServerConf; sc.DOClientID != "" && sc.DOClientSecret != "" {
+	if sc.DOClientID != "" && sc.DOClientSecret != "" {
 		app.DOConf = oauth.NewDigitalOceanClient(&oauth.Config{
 			ClientID:     sc.DOClientID,
 			ClientSecret: sc.DOClientSecret,
@@ -153,6 +201,10 @@ func New(conf *AppConfig) (*App, error) {
 		})
 	}
 
+	app.Capabilities.Email = sc.SendgridAPIKey != ""
+	app.Capabilities.Analytics = sc.SegmentClientKey != ""
+	app.Capabilities.BasicLogin = sc.BasicLoginEnabled
+
 	app.tokenConf = &token.TokenGeneratorConf{
 		TokenSecret: conf.ServerConf.TokenGeneratorSecret,
 	}

+ 1 - 15
server/api/capability_handler.go

@@ -5,23 +5,9 @@ import (
 	"net/http"
 )
 
-// CapabilitiesExternal represents the Capabilities struct that will be sent over REST
-type CapabilitiesExternal struct {
-	Provisioner bool `json:"provisioner"`
-	GitHub bool	`json:"github"`
-}
-
 // HandleGetCapabilities gets the capabilities of the server
 func (app *App) HandleGetCapabilities(w http.ResponseWriter, r *http.Request) {
-
-	cap := app.CapConf
-
-	capExternal := &CapabilitiesExternal{
-		Provisioner: cap.Provisioner,
-		GitHub: cap.Github,
-	}
-
-	if err := json.NewEncoder(w).Encode(capExternal); err != nil {
+	if err := json.NewEncoder(w).Encode(app.Capabilities); err != nil {
 		app.handleErrorFormDecoding(err, ErrK8sDecode, w)
 		return
 	}

+ 1 - 0
server/api/deploy_handler.go

@@ -251,6 +251,7 @@ func (app *App) HandleUninstallTemplate(w http.ResponseWriter, r *http.Request)
 				}
 
 				gaRunner := &actions.GithubActions{
+					ServerURL:      app.ServerConf.ServerURL,
 					GitIntegration: gr,
 					GitRepoName:    repoSplit[1],
 					GitRepoOwner:   repoSplit[0],

+ 1 - 9
server/api/dns_record_handler.go

@@ -74,17 +74,9 @@ func (app *App) HandleCreateDNSRecord(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	// launch provisioning destruction pod
-	inClusterAgent, err := kubernetes.GetAgentInClusterConfig()
-
-	if err != nil {
-		app.handleErrorDataRead(err, w)
-		return
-	}
-
 	_record := domain.DNSRecord(*record)
 
-	err = _record.CreateDomain(inClusterAgent.Clientset)
+	err = _record.CreateDomain(app.InClusterAgent.Clientset)
 
 	if err != nil {
 		app.handleErrorInternal(err, w)

+ 1 - 0
server/api/git_action_handler.go

@@ -153,6 +153,7 @@ func (app *App) createGitActionFromForm(
 
 	// create the commit in the git repo
 	gaRunner := &actions.GithubActions{
+		ServerURL:      app.ServerConf.ServerURL,
 		GitIntegration: gr,
 		GitRepoName:    repoSplit[1],
 		GitRepoOwner:   repoSplit[0],

+ 113 - 0
server/api/k8s_handler.go

@@ -75,6 +75,119 @@ func (app *App) HandleListNamespaces(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
+// HandleCreateNamespace creates a new namespace given the name.
+func (app *App) HandleCreateNamespace(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+	fmt.Println(vals)
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	ns := &forms.NamespaceForm{}
+
+	if err := json.NewDecoder(r.Body).Decode(ns); err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	namespace, err := agent.CreateNamespace(ns.Name)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	if err := json.NewEncoder(w).Encode(namespace); err != nil {
+		app.handleErrorFormDecoding(err, ErrK8sDecode, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
+// HandleDeleteNamespace deletes a namespace given the name.
+func (app *App) HandleDeleteNamespace(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	namespace := &forms.NamespaceForm{}
+
+	if err := json.NewDecoder(r.Body).Decode(namespace); err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	err = agent.DeleteNamespace(namespace.Name)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
 // HandleListPodEvents retrieves all events tied to a pod.
 func (app *App) HandleListPodEvents(w http.ResponseWriter, r *http.Request) {
 	vals, err := url.ParseQuery(r.URL.RawQuery)

+ 213 - 0
server/api/oauth_google_handler.go

@@ -0,0 +1,213 @@
+package api
+
+import (
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/url"
+	"strings"
+
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+
+	"github.com/porter-dev/porter/internal/oauth"
+	"golang.org/x/oauth2"
+
+	segment "gopkg.in/segmentio/analytics-go.v3"
+)
+
+// HandleGoogleStartUser starts the oauth2 flow for a user login request.
+func (app *App) HandleGoogleStartUser(w http.ResponseWriter, r *http.Request) {
+	state := oauth.CreateRandomState()
+
+	err := app.populateOAuthSession(w, r, state, false)
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	// specify access type offline to get a refresh token
+	url := app.GoogleUserConf.AuthCodeURL(state, oauth2.AccessTypeOnline)
+
+	http.Redirect(w, r, url, 302)
+}
+
+// HandleGithubOAuthCallback verifies the callback request by checking that the
+// state parameter has not been modified, and validates the token.
+//
+// When logging a user in, the access token gets stored in the session, and no refresh
+// token is requested. We store the access token in the session because a user can be
+// logged in multiple times with a single access token.
+func (app *App) HandleGoogleOAuthCallback(w http.ResponseWriter, r *http.Request) {
+	session, err := app.Store.Get(r, app.ServerConf.CookieName)
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	if _, ok := session.Values["state"]; !ok {
+		app.sendExternalError(
+			err,
+			http.StatusForbidden,
+			HTTPError{
+				Code: http.StatusForbidden,
+				Errors: []string{
+					"Could not read cookie: are cookies enabled?",
+				},
+			},
+			w,
+		)
+
+		return
+	}
+
+	if r.URL.Query().Get("state") != session.Values["state"] {
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+
+	token, err := app.GoogleUserConf.Exchange(oauth2.NoContext, r.URL.Query().Get("code"))
+
+	if err != nil {
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+
+	if !token.Valid() {
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+
+	// create the user if not exists
+	user, err := app.upsertGoogleUserFromToken(token)
+
+	if err != nil && strings.Contains(err.Error(), "already registered") {
+		http.Redirect(w, r, "/login?error="+url.QueryEscape(err.Error()), 302)
+		return
+	} else if err != nil && strings.Contains(err.Error(), "restricted domain group") {
+		http.Redirect(w, r, "/login?error="+url.QueryEscape(err.Error()), 302)
+		return
+	} else if err != nil {
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+
+	// send to segment
+	if app.segmentClient != nil {
+		client := *app.segmentClient
+		client.Enqueue(segment.Identify{
+			UserId: fmt.Sprintf("%v", user.ID),
+			Traits: segment.NewTraits().
+				SetEmail(user.Email).
+				Set("github", "true"),
+		})
+
+		client.Enqueue(segment.Track{
+			UserId: fmt.Sprintf("%v", user.ID),
+			Event:  "New User",
+			Properties: segment.NewProperties().
+				Set("email", user.Email),
+		})
+	}
+
+	// log the user in
+	app.Logger.Info().Msgf("New user created: %d", user.ID)
+
+	session.Values["authenticated"] = true
+	session.Values["user_id"] = user.ID
+	session.Values["email"] = user.Email
+	session.Values["redirect"] = ""
+	session.Save(r, w)
+
+	if session.Values["query_params"] != "" {
+		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
+	} else {
+		http.Redirect(w, r, "/dashboard", 302)
+	}
+}
+
+type googleUserInfo struct {
+	Email         string `json:"email"`
+	EmailVerified bool   `json:"email_verified"`
+	HD            string `json:"hd"`
+	Sub           string `json:"sub"`
+}
+
+func (app *App) upsertGoogleUserFromToken(tok *oauth2.Token) (*models.User, error) {
+	gInfo, err := getGoogleUserInfoFromToken(tok)
+
+	if err != nil {
+		return nil, err
+	}
+
+	// if the app has a restricted domain, check the `hd` query param
+	if app.ServerConf.GoogleRestrictedDomain != "" {
+		if gInfo.HD != "bloomchat.app" {
+			return nil, fmt.Errorf("Email is not in the restricted domain group.")
+		}
+	}
+
+	user, err := app.Repo.User.ReadUserByGoogleUserID(gInfo.Sub)
+
+	// if the user does not exist, create new user
+	if err != nil && err == gorm.ErrRecordNotFound {
+		// check if a user with that email address already exists
+		_, err = app.Repo.User.ReadUserByEmail(gInfo.Email)
+
+		if err == gorm.ErrRecordNotFound {
+			user = &models.User{
+				Email:         gInfo.Email,
+				EmailVerified: gInfo.EmailVerified,
+				GoogleUserID:  gInfo.Sub,
+			}
+
+			user, err = app.Repo.User.CreateUser(user)
+
+			if err != nil {
+				return nil, err
+			}
+		} else if err == nil {
+			return nil, fmt.Errorf("email already registered")
+		} else if err != nil {
+			return nil, err
+		}
+	} else if err != nil {
+		return nil, fmt.Errorf("unexpected error occurred:%s", err.Error())
+	}
+
+	return user, nil
+}
+
+func getGoogleUserInfoFromToken(tok *oauth2.Token) (*googleUserInfo, error) {
+	// use userinfo endpoint for Google OIDC to get claims
+	url := "https://openidconnect.googleapis.com/v1/userinfo"
+
+	req, err := http.NewRequest("GET", url, nil)
+
+	req.Header.Add("Authorization", "Bearer "+tok.AccessToken)
+
+	client := &http.Client{}
+
+	response, err := client.Do(req)
+
+	if err != nil {
+		return nil, fmt.Errorf("failed getting user info: %s", err.Error())
+	}
+
+	defer response.Body.Close()
+
+	contents, err := ioutil.ReadAll(response.Body)
+
+	if err != nil {
+		return nil, fmt.Errorf("failed reading response body: %s", err.Error())
+	}
+
+	// parse contents into Google userinfo claims
+	gInfo := &googleUserInfo{}
+	err = json.Unmarshal(contents, &gInfo)
+
+	return gInfo, nil
+}

+ 12 - 130
server/api/provision_handler.go

@@ -25,14 +25,6 @@ func (app *App) HandleProvisionTestInfra(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	// create a new agent
-	agent, err := kubernetes.GetAgentInClusterConfig()
-
-	if err != nil {
-		app.handleErrorDataRead(err, w)
-		return
-	}
-
 	form := &forms.CreateTestInfra{
 		ProjectID: uint(projID),
 	}
@@ -59,7 +51,7 @@ func (app *App) HandleProvisionTestInfra(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	_, err = agent.ProvisionTest(
+	_, err = app.InClusterAgent.ProvisionTest(
 		uint(projID),
 		infra,
 		*app.Repo,
@@ -199,17 +191,7 @@ func (app *App) HandleProvisionAWSECRInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	// launch provisioning pod
-	agent, err := kubernetes.GetAgentInClusterConfig()
-
-	if err != nil {
-		infra.Status = models.StatusError
-		infra, _ = app.Repo.Infra.UpdateInfra(infra)
-
-		app.handleErrorDataRead(err, w)
-		return
-	}
-
-	_, err = agent.ProvisionECR(
+	_, err = app.InClusterAgent.ProvisionECR(
 		uint(projID),
 		awsInt,
 		form.ECRName,
@@ -282,16 +264,6 @@ func (app *App) HandleDestroyAWSECRInfra(w http.ResponseWriter, r *http.Request)
 	}
 
 	// launch provisioning destruction pod
-	agent, err := kubernetes.GetAgentInClusterConfig()
-
-	if err != nil {
-		infra.Status = models.StatusError
-		infra, _ = app.Repo.Infra.UpdateInfra(infra)
-
-		app.handleErrorDataRead(err, w)
-		return
-	}
-
 	// mark infra for deletion
 	infra.Status = models.StatusDestroying
 	infra, err = app.Repo.Infra.UpdateInfra(infra)
@@ -301,7 +273,7 @@ func (app *App) HandleDestroyAWSECRInfra(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	_, err = agent.ProvisionECR(
+	_, err = app.InClusterAgent.ProvisionECR(
 		infra.ProjectID,
 		awsInt,
 		form.ECRName,
@@ -375,17 +347,7 @@ func (app *App) HandleProvisionAWSEKSInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	// launch provisioning pod
-	agent, err := kubernetes.GetAgentInClusterConfig()
-
-	if err != nil {
-		infra.Status = models.StatusError
-		infra, _ = app.Repo.Infra.UpdateInfra(infra)
-
-		app.handleErrorDataRead(err, w)
-		return
-	}
-
-	_, err = agent.ProvisionEKS(
+	_, err = app.InClusterAgent.ProvisionEKS(
 		uint(projID),
 		awsInt,
 		form.EKSName,
@@ -459,16 +421,6 @@ func (app *App) HandleDestroyAWSEKSInfra(w http.ResponseWriter, r *http.Request)
 	}
 
 	// launch provisioning destruction pod
-	agent, err := kubernetes.GetAgentInClusterConfig()
-
-	if err != nil {
-		infra.Status = models.StatusError
-		infra, _ = app.Repo.Infra.UpdateInfra(infra)
-
-		app.handleErrorDataRead(err, w)
-		return
-	}
-
 	// mark infra for deletion
 	infra.Status = models.StatusDestroying
 	infra, err = app.Repo.Infra.UpdateInfra(infra)
@@ -478,7 +430,7 @@ func (app *App) HandleDestroyAWSEKSInfra(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	_, err = agent.ProvisionEKS(
+	_, err = app.InClusterAgent.ProvisionEKS(
 		infra.ProjectID,
 		awsInt,
 		form.EKSName,
@@ -553,17 +505,7 @@ func (app *App) HandleProvisionGCPGCRInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	// launch provisioning pod
-	agent, err := kubernetes.GetAgentInClusterConfig()
-
-	if err != nil {
-		infra.Status = models.StatusError
-		infra, _ = app.Repo.Infra.UpdateInfra(infra)
-
-		app.handleErrorDataRead(err, w)
-		return
-	}
-
-	_, err = agent.ProvisionGCR(
+	_, err = app.InClusterAgent.ProvisionGCR(
 		uint(projID),
 		gcpInt,
 		*app.Repo,
@@ -646,17 +588,7 @@ func (app *App) HandleProvisionGCPGKEInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	// launch provisioning pod
-	agent, err := kubernetes.GetAgentInClusterConfig()
-
-	if err != nil {
-		infra.Status = models.StatusError
-		infra, _ = app.Repo.Infra.UpdateInfra(infra)
-
-		app.handleErrorDataRead(err, w)
-		return
-	}
-
-	_, err = agent.ProvisionGKE(
+	_, err = app.InClusterAgent.ProvisionGKE(
 		uint(projID),
 		gcpInt,
 		form.GKEName,
@@ -729,16 +661,6 @@ func (app *App) HandleDestroyGCPGKEInfra(w http.ResponseWriter, r *http.Request)
 	}
 
 	// launch provisioning destruction pod
-	agent, err := kubernetes.GetAgentInClusterConfig()
-
-	if err != nil {
-		infra.Status = models.StatusError
-		infra, _ = app.Repo.Infra.UpdateInfra(infra)
-
-		app.handleErrorDataRead(err, w)
-		return
-	}
-
 	// mark infra for deletion
 	infra.Status = models.StatusDestroying
 	infra, err = app.Repo.Infra.UpdateInfra(infra)
@@ -748,7 +670,7 @@ func (app *App) HandleDestroyGCPGKEInfra(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	_, err = agent.ProvisionGKE(
+	_, err = app.InClusterAgent.ProvisionGKE(
 		infra.ProjectID,
 		gcpInt,
 		form.GKEName,
@@ -866,17 +788,7 @@ func (app *App) HandleProvisionDODOCRInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	// launch provisioning pod
-	agent, err := kubernetes.GetAgentInClusterConfig()
-
-	if err != nil {
-		infra.Status = models.StatusError
-		infra, _ = app.Repo.Infra.UpdateInfra(infra)
-
-		app.handleErrorDataRead(err, w)
-		return
-	}
-
-	_, err = agent.ProvisionDOCR(
+	_, err = app.InClusterAgent.ProvisionDOCR(
 		uint(projID),
 		oauthInt,
 		app.DOConf,
@@ -951,16 +863,6 @@ func (app *App) HandleDestroyDODOCRInfra(w http.ResponseWriter, r *http.Request)
 	}
 
 	// launch provisioning destruction pod
-	agent, err := kubernetes.GetAgentInClusterConfig()
-
-	if err != nil {
-		infra.Status = models.StatusError
-		infra, _ = app.Repo.Infra.UpdateInfra(infra)
-
-		app.handleErrorDataRead(err, w)
-		return
-	}
-
 	// mark infra for deletion
 	infra.Status = models.StatusDestroying
 	infra, err = app.Repo.Infra.UpdateInfra(infra)
@@ -970,7 +872,7 @@ func (app *App) HandleDestroyDODOCRInfra(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	_, err = agent.ProvisionDOCR(
+	_, err = app.InClusterAgent.ProvisionDOCR(
 		infra.ProjectID,
 		oauthInt,
 		app.DOConf,
@@ -1046,17 +948,7 @@ func (app *App) HandleProvisionDODOKSInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	// launch provisioning pod
-	agent, err := kubernetes.GetAgentInClusterConfig()
-
-	if err != nil {
-		infra.Status = models.StatusError
-		infra, _ = app.Repo.Infra.UpdateInfra(infra)
-
-		app.handleErrorDataRead(err, w)
-		return
-	}
-
-	_, err = agent.ProvisionDOKS(
+	_, err = app.InClusterAgent.ProvisionDOKS(
 		uint(projID),
 		oauthInt,
 		app.DOConf,
@@ -1131,16 +1023,6 @@ func (app *App) HandleDestroyDODOKSInfra(w http.ResponseWriter, r *http.Request)
 	}
 
 	// launch provisioning destruction pod
-	agent, err := kubernetes.GetAgentInClusterConfig()
-
-	if err != nil {
-		infra.Status = models.StatusError
-		infra, _ = app.Repo.Infra.UpdateInfra(infra)
-
-		app.handleErrorDataRead(err, w)
-		return
-	}
-
 	// mark infra for deletion
 	infra.Status = models.StatusDestroying
 	infra, err = app.Repo.Infra.UpdateInfra(infra)
@@ -1150,7 +1032,7 @@ func (app *App) HandleDestroyDODOKSInfra(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	_, err = agent.ProvisionDOKS(
+	_, err = app.InClusterAgent.ProvisionDOKS(
 		infra.ProjectID,
 		oauthInt,
 		app.DOConf,

+ 4 - 2
server/api/release_handler.go

@@ -751,7 +751,7 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 	if err != nil {
 		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
 			Code:   ErrReleaseDeploy,
-			Errors: []string{"error upgrading release " + err.Error()},
+			Errors: []string{err.Error()},
 		}, w)
 
 		return
@@ -791,6 +791,7 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 				repoSplit := strings.Split(gitAction.GitRepo, "/")
 
 				gaRunner := &actions.GithubActions{
+					ServerURL:      app.ServerConf.ServerURL,
 					GitIntegration: gr,
 					GitRepoName:    repoSplit[1],
 					GitRepoOwner:   repoSplit[0],
@@ -923,7 +924,7 @@ func (app *App) HandleReleaseDeployWebhook(w http.ResponseWriter, r *http.Reques
 	if err != nil {
 		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
 			Code:   ErrReleaseDeploy,
-			Errors: []string{"error upgrading release " + err.Error()},
+			Errors: []string{err.Error()},
 		}, w)
 
 		return
@@ -1056,6 +1057,7 @@ func (app *App) HandleRollbackRelease(w http.ResponseWriter, r *http.Request) {
 				}
 
 				gaRunner := &actions.GithubActions{
+					ServerURL:      app.ServerConf.ServerURL,
 					GitIntegration: gr,
 					GitRepoName:    repoSplit[1],
 					GitRepoOwner:   repoSplit[0],

+ 0 - 0
server/router/middleware/auth.go → server/middleware/auth.go


+ 0 - 0
server/router/middleware/json.go → server/middleware/json.go


+ 0 - 0
server/requestlog/handler.go → server/middleware/requestlog/handler.go


+ 0 - 0
server/requestlog/log_entry.go → server/middleware/requestlog/log_entry.go


+ 56 - 13
server/router/router.go

@@ -11,8 +11,8 @@ import (
 	"github.com/go-chi/chi/middleware"
 	"github.com/porter-dev/porter/internal/auth/token"
 	"github.com/porter-dev/porter/server/api"
-	"github.com/porter-dev/porter/server/requestlog"
-	mw "github.com/porter-dev/porter/server/router/middleware"
+	mw "github.com/porter-dev/porter/server/middleware"
+	"github.com/porter-dev/porter/server/middleware/requestlog"
 )
 
 // New creates a new Chi router instance and registers all routes supported by the
@@ -55,11 +55,20 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
-			r.Method(
-				"POST",
-				"/users",
-				requestlog.NewHandler(a.HandleCreateUser, l),
-			)
+			// only allow basic create user or basic login if BasicLogin feature is set
+			if a.Capabilities.BasicLogin {
+				r.Method(
+					"POST",
+					"/users",
+					requestlog.NewHandler(a.HandleCreateUser, l),
+				)
+
+				r.Method(
+					"POST",
+					"/login",
+					requestlog.NewHandler(a.HandleLoginUser, l),
+				)
+			}
 
 			r.Method(
 				"DELETE",
@@ -84,12 +93,6 @@ func New(a *api.App) *chi.Mux {
 				requestlog.NewHandler(a.HandleCLILoginExchangeToken, l),
 			)
 
-			r.Method(
-				"POST",
-				"/login",
-				requestlog.NewHandler(a.HandleLoginUser, l),
-			)
-
 			r.Method(
 				"GET",
 				"/auth/check",
@@ -212,6 +215,18 @@ func New(a *api.App) *chi.Mux {
 				requestlog.NewHandler(a.HandleGithubOAuthCallback, l),
 			)
 
+			r.Method(
+				"GET",
+				"/oauth/login/google",
+				requestlog.NewHandler(a.HandleGoogleStartUser, l),
+			)
+
+			r.Method(
+				"GET",
+				"/oauth/google/callback",
+				requestlog.NewHandler(a.HandleGoogleOAuthCallback, l),
+			)
+
 			r.Method(
 				"GET",
 				"/oauth/projects/{project_id}/digitalocean",
@@ -1056,6 +1071,34 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			r.Method(
+				"POST",
+				"/projects/{project_id}/k8s/namespaces/create",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleCreateNamespace, l),
+						mw.URLParam,
+						mw.QueryParam,
+					),
+					mw.URLParam,
+					mw.ReadAccess,
+				),
+			)
+
+			r.Method(
+				"DELETE",
+				"/projects/{project_id}/k8s/namespaces/delete",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleDeleteNamespace, l),
+						mw.URLParam,
+						mw.QueryParam,
+					),
+					mw.URLParam,
+					mw.ReadAccess,
+				),
+			)
+
 			r.Method(
 				"GET",
 				"/projects/{project_id}/k8s/kubeconfig",