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

Merge branch 'master' of https://github.com/jnfrati/porter into 251-cluster-ip-address-on-dashboard

jnfrati 5 роки тому
батько
коміт
19e35bb7a4
48 змінених файлів з 1212 додано та 476 видалено
  1. 1 1
      README.md
  2. 6 2
      cmd/app/main.go
  3. 1 0
      dashboard/decs.d.ts
  4. 11 10
      dashboard/package-lock.json
  5. 1 2
      dashboard/package.json
  6. 123 0
      dashboard/src/assets/GoogleIcon.tsx
  7. 6 0
      dashboard/src/components/SaveButton.tsx
  8. 97 14
      dashboard/src/main/CurrentError.tsx
  9. 125 60
      dashboard/src/main/auth/Login.tsx
  10. 111 62
      dashboard/src/main/auth/Register.tsx
  11. 3 2
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  12. 29 6
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  13. 64 47
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  14. 13 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  15. 12 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ValuesYaml.tsx
  16. 96 46
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  17. 1 4
      dashboard/src/main/home/launch/Launch.tsx
  18. 1 1
      dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx
  19. 8 3
      dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx
  20. 1 1
      dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx
  21. 3 3
      dashboard/src/main/home/project-settings/InviteList.tsx
  22. 2 2
      dashboard/src/main/home/provisioner/ProvisionerLogs.tsx
  23. 28 0
      docs/guides/template-versioning-and-upgrades.md
  24. 26 4
      internal/adapter/gorm.go
  25. 19 6
      internal/config/config.go
  26. 4 2
      internal/integrations/ci/actions/actions.go
  27. 6 5
      internal/integrations/ci/actions/steps.go
  28. 10 0
      internal/kubernetes/config.go
  29. 1 0
      internal/models/integrations/oauth.go
  30. 1 0
      internal/models/user.go
  31. 13 0
      internal/oauth/config.go
  32. 9 0
      internal/repository/gorm/user.go
  33. 32 0
      internal/repository/gorm/user_test.go
  34. 15 0
      internal/repository/memory/user.go
  35. 1 0
      internal/repository/user.go
  36. 71 19
      server/api/api.go
  37. 1 15
      server/api/capability_handler.go
  38. 1 0
      server/api/deploy_handler.go
  39. 1 9
      server/api/dns_record_handler.go
  40. 1 0
      server/api/git_action_handler.go
  41. 213 0
      server/api/oauth_google_handler.go
  42. 12 130
      server/api/provision_handler.go
  43. 4 2
      server/api/release_handler.go
  44. 0 0
      server/middleware/auth.go
  45. 0 0
      server/middleware/json.go
  46. 0 0
      server/middleware/requestlog/handler.go
  47. 0 0
      server/middleware/requestlog/log_entry.go
  48. 28 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
 - Connect to existing Kubernetes clusters that are not provisioned by Porter
 - Visualize, deploy, and configure Helm charts via the GUI
 - 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
 - In-depth view of releases, including revision histories and component graphs
 - Rollback/update of existing releases, including editing of raw `values.yaml`
 - Rollback/update of existing releases, including editing of raw `values.yaml`
 
 

+ 6 - 2
cmd/app/main.go

@@ -102,15 +102,19 @@ func main() {
 		go prov.GlobalStreamListener(redis, *repo, errorChan)
 		go prov.GlobalStreamListener(redis, *repo, errorChan)
 	}
 	}
 
 
-	a, _ := api.New(&api.AppConfig{
+	a, err := api.New(&api.AppConfig{
 		Logger:     logger,
 		Logger:     logger,
 		Repository: repo,
 		Repository: repo,
 		ServerConf: appConf.Server,
 		ServerConf: appConf.Server,
 		RedisConf:  &appConf.Redis,
 		RedisConf:  &appConf.Redis,
-		CapConf: 	appConf.Capabilities,
+		CapConf:    appConf.Capabilities,
 		DBConf:     appConf.Db,
 		DBConf:     appConf.Db,
 	})
 	})
 
 
+	if err != nil {
+		logger.Fatal().Err(err).Msg("")
+	}
+
 	appRouter := router.New(a)
 	appRouter := router.New(a)
 
 
 	address := fmt.Sprintf(":%d", appConf.Server.Port)
 	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==",
       "integrity": "sha512-BnEyOcDE4H6bkg8m84xhdbkYoAoCg8sYERmAvE4Ff50U8jTfbmOinRdJpauBn1P9XsCCQgCLuSiyz3PM4WHYOA==",
       "dev": true
       "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": {
     "@types/json-schema": {
       "version": "7.0.6",
       "version": "7.0.6",
       "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.6.tgz",
       "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.6.tgz",
@@ -4811,12 +4806,18 @@
       "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
       "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
     },
     },
     "js-yaml": {
     "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": {
       "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": {
     "jsesc": {

+ 1 - 2
dashboard/package.json

@@ -6,7 +6,6 @@
     "@material-ui/core": "^4.11.3",
     "@material-ui/core": "^4.11.3",
     "@types/d3-array": "^2.9.0",
     "@types/d3-array": "^2.9.0",
     "@types/d3-time-format": "^3.0.0",
     "@types/d3-time-format": "^3.0.0",
-    "@types/js-yaml": "^3.12.5",
     "@types/lodash": "^4.14.165",
     "@types/lodash": "^4.14.165",
     "@types/markdown-to-jsx": "^6.11.3",
     "@types/markdown-to-jsx": "^6.11.3",
     "@types/material-ui": "^0.21.8",
     "@types/material-ui": "^0.21.8",
@@ -33,7 +32,7 @@
     "highlight.run": "^1.4.5",
     "highlight.run": "^1.4.5",
     "ini": ">=1.3.6",
     "ini": ">=1.3.6",
     "js-base64": "^3.6.0",
     "js-base64": "^3.6.0",
-    "js-yaml": "^3.14.0",
+    "js-yaml": "^4.1.0",
     "lodash": "^4.17.20",
     "lodash": "^4.17.20",
     "markdown-to-jsx": "^7.0.1",
     "markdown-to-jsx": "^7.0.1",
     "qs": "^6.9.4",
     "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;
   font-size: 13px;
   color: #ffffff55;
   color: #ffffff55;
   margin-right: 25px;
   margin-right: 25px;
+  padding: 0 10px;
+
+  max-width: 500px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
 
 
   > i {
   > i {
     font-size: 18px;
     font-size: 18px;

+ 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.props.currentError) {
       if (!this.state.expanded) {
       if (!this.state.expanded) {
         return (
         return (
-          <StyledCurrentError onClick={() => this.setState({ expanded: true })}>
+          <StyledCurrentError>
             <ErrorText>Error: {this.props.currentError}</ErrorText>
             <ErrorText>Error: {this.props.currentError}</ErrorText>
+            <ExpandButton onClick={() => this.setState({ expanded: true })}>
+              <i className="material-icons">launch</i>
+            </ExpandButton>
             <CloseButton
             <CloseButton
               onClick={(e) => {
               onClick={(e) => {
-                this.context.setCurrentError(null);
                 e.stopPropagation();
                 e.stopPropagation();
+
+                this.setState({ expanded: false }, () => {
+                  this.context.setCurrentError(null);
+                });
               }}
               }}
             >
             >
               <CloseButtonImg src={close} />
               <CloseButtonImg src={close} />
@@ -44,12 +50,26 @@ export default class CurrentError extends Component<PropsType, StateType> {
       }
       }
 
 
       return (
       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;
   width: 30px;
   height: 30px;
   height: 30px;
   border-radius: 50%;
   border-radius: 50%;
-  margin-left: 10px;
   cursor: pointer;
   cursor: pointer;
   :hover {
   :hover {
     background-color: #ffffff11;
     background-color: #ffffff11;
@@ -87,13 +106,13 @@ const ErrorText = styled.div`
   white-space: nowrap;
   white-space: nowrap;
   overflow: hidden;
   overflow: hidden;
   text-overflow: ellipsis;
   text-overflow: ellipsis;
-  width: calc(100% - 50px);
+  width: calc(100% - 80px);
 `;
 `;
 
 
 const StyledCurrentError = styled.div`
 const StyledCurrentError = styled.div`
   position: fixed;
   position: fixed;
   bottom: 22px;
   bottom: 22px;
-  width: 300px;
+  width: 310px;
   left: 20px;
   left: 20px;
   padding: 15px;
   padding: 15px;
   padding-right: 0px;
   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;
   height: auto;
-  max-height: 300px;
+  max-height: 500px;
+  top: 50%;
+  transform: translateY(-50%);
   padding: 20px;
   padding: 20px;
   overflow-y: auto;
   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 styled from "styled-components";
 import logo from "assets/logo.png";
 import logo from "assets/logo.png";
 import github from "assets/github-icon.png";
 import github from "assets/github-icon.png";
+import GoogleIcon from "assets/GoogleIcon";
 
 
 import api from "shared/api";
 import api from "shared/api";
 import { emailRegex } from "shared/regex";
 import { emailRegex } from "shared/regex";
@@ -16,7 +17,10 @@ type StateType = {
   password: string;
   password: string;
   emailError: boolean;
   emailError: boolean;
   credentialError: boolean;
   credentialError: boolean;
+  hasBasic: boolean;
   hasGithub: boolean;
   hasGithub: boolean;
+  hasGoogle: boolean;
+  hasResetPassword: boolean;
 };
 };
 
 
 export default class Login extends Component<PropsType, StateType> {
 export default class Login extends Component<PropsType, StateType> {
@@ -25,7 +29,10 @@ export default class Login extends Component<PropsType, StateType> {
     password: "",
     password: "",
     emailError: false,
     emailError: false,
     credentialError: false,
     credentialError: false,
+    hasBasic: true,
     hasGithub: true,
     hasGithub: true,
+    hasGoogle: false,
+    hasResetPassword: true,
   };
   };
 
 
   handleKeyDown = (e: any) => {
   handleKeyDown = (e: any) => {
@@ -43,7 +50,12 @@ export default class Login extends Component<PropsType, StateType> {
     api
     api
       .getCapabilities("", {}, {})
       .getCapabilities("", {}, {})
       .then((res) => {
       .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));
       .catch((err) => console.log(err));
   }
   }
@@ -115,31 +127,104 @@ export default class Login extends Component<PropsType, StateType> {
     window.location.href = redirectUrl;
     window.location.href = redirectUrl;
   };
   };
 
 
+  googleRedirect = () => {
+    let redirectUrl = `/api/oauth/login/google`;
+    window.location.href = redirectUrl;
+  };
+
   renderGithubSection = () => {
   renderGithubSection = () => {
     if (this.state.hasGithub) {
     if (this.state.hasGithub) {
       return (
       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 (
     return (
       <StyledLogin>
       <StyledLogin>
-        <LoginPanel>
+        <LoginPanel
+          hasBasic={this.state.hasBasic}
+          numOAuth={+this.state.hasGithub + +this.state.hasGoogle}
+        >
           <OverflowWrapper>
           <OverflowWrapper>
             <GradientBg />
             <GradientBg />
           </OverflowWrapper>
           </OverflowWrapper>
@@ -147,47 +232,19 @@ export default class Login extends Component<PropsType, StateType> {
             <Logo src={logo} />
             <Logo src={logo} />
             <Prompt>Log in to Porter</Prompt>
             <Prompt>Log in to Porter</Prompt>
             {this.renderGithubSection()}
             {this.renderGithubSection()}
+            {this.renderGoogleSection()}
+            {(this.state.hasGithub || this.state.hasGoogle) &&
+            this.state.hasBasic ? (
+              <OrWrapper>
+                <Line />
+                <Or>or</Or>
+              </OrWrapper>
+            ) : null}
             <DarkMatter />
             <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>
           </FormWrapper>
         </LoginPanel>
         </LoginPanel>
-
         <Footer>
         <Footer>
           © 2021 Porter Technologies Inc. •
           © 2021 Porter Technologies Inc. •
           <Link
           <Link
@@ -249,7 +306,12 @@ const IconWrapper = styled.div`
 
 
 const Icon = styled.img`
 const Icon = styled.img`
   height: 18px;
   height: 18px;
-  margin-right: 20px;
+  margin: 14px;
+`;
+
+const StyledGoogleIcon = styled(GoogleIcon)`
+  width: 38px;
+  height: 38px;
 `;
 `;
 
 
 const OAuthButton = styled.div`
 const OAuthButton = styled.div`
@@ -264,6 +326,8 @@ const OAuthButton = styled.div`
   user-select: none;
   user-select: none;
   font-weight: 500;
   font-weight: 500;
   font-size: 13px;
   font-size: 13px;
+  margin: 10px 0;
+  overflow: hidden;
   :hover {
   :hover {
     background: #ffffffdd;
     background: #ffffffdd;
   }
   }
@@ -392,11 +456,11 @@ const FormWrapper = styled.div`
 
 
 const GradientBg = styled.div`
 const GradientBg = styled.div`
   background: linear-gradient(#8ce1ff, #a59eff, #fba8ff);
   background: linear-gradient(#8ce1ff, #a59eff, #fba8ff);
-  width: 180%;
-  height: 180%;
+  width: 200%;
+  height: 200%;
   position: absolute;
   position: absolute;
-  top: -40%;
-  left: -40%;
+  top: -50%;
+  left: -50%;
   animation: flip 6s infinite linear;
   animation: flip 6s infinite linear;
   @keyframes flip {
   @keyframes flip {
     from {
     from {
@@ -410,7 +474,8 @@ const GradientBg = styled.div`
 
 
 const LoginPanel = styled.div`
 const LoginPanel = styled.div`
   width: 330px;
   width: 330px;
-  height: 470px;
+  height: ${(props: { numOAuth: number; hasBasic: boolean }) =>
+    280 + +props.hasBasic * 150 + props.numOAuth * 50}px;
   background: white;
   background: white;
   margin-top: -20px;
   margin-top: -20px;
   border-radius: 10px;
   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 styled from "styled-components";
 import logo from "assets/logo.png";
 import logo from "assets/logo.png";
 import github from "assets/github-icon.png";
 import github from "assets/github-icon.png";
+import GoogleIcon from "assets/GoogleIcon";
 
 
 import api from "shared/api";
 import api from "shared/api";
 import { emailRegex } from "shared/regex";
 import { emailRegex } from "shared/regex";
@@ -18,6 +19,8 @@ type StateType = {
   emailError: boolean;
   emailError: boolean;
   confirmPasswordError: boolean;
   confirmPasswordError: boolean;
   hasGithub: boolean;
   hasGithub: boolean;
+  hasGoogle: boolean;
+  hasBasic: boolean;
 };
 };
 
 
 export default class Register extends Component<PropsType, StateType> {
 export default class Register extends Component<PropsType, StateType> {
@@ -27,7 +30,9 @@ export default class Register extends Component<PropsType, StateType> {
     confirmPassword: "",
     confirmPassword: "",
     emailError: false,
     emailError: false,
     confirmPasswordError: false,
     confirmPasswordError: false,
+    hasBasic: true,
     hasGithub: true,
     hasGithub: true,
+    hasGoogle: false,
   };
   };
 
 
   handleKeyDown = (e: any) => {
   handleKeyDown = (e: any) => {
@@ -41,7 +46,11 @@ export default class Register extends Component<PropsType, StateType> {
     api
     api
       .getCapabilities("", {}, {})
       .getCapabilities("", {}, {})
       .then((res) => {
       .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));
       .catch((err) => console.log(err));
   }
   }
@@ -55,6 +64,11 @@ export default class Register extends Component<PropsType, StateType> {
     window.location.href = redirectUrl;
     window.location.href = redirectUrl;
   };
   };
 
 
+  googleRedirect = () => {
+    let redirectUrl = `/api/oauth/login/google`;
+    window.location.href = redirectUrl;
+  };
+
   handleRegister = (): void => {
   handleRegister = (): void => {
     let { email, password, confirmPassword } = this.state;
     let { email, password, confirmPassword } = this.state;
     let { authenticate } = this.props;
     let { authenticate } = this.props;
@@ -119,23 +133,30 @@ export default class Register extends Component<PropsType, StateType> {
   renderGithubSection = () => {
   renderGithubSection = () => {
     if (this.state.hasGithub) {
     if (this.state.hasGithub) {
       return (
       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 {
     let {
       email,
       email,
       password,
       password,
@@ -144,9 +165,61 @@ export default class Register extends Component<PropsType, StateType> {
       confirmPasswordError,
       confirmPasswordError,
     } = this.state;
     } = 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 (
     return (
       <StyledRegister>
       <StyledRegister>
-        <LoginPanel>
+        <LoginPanel
+          hasBasic={this.state.hasBasic}
+          numOAuth={+this.state.hasGithub + +this.state.hasGoogle}
+        >
           <OverflowWrapper>
           <OverflowWrapper>
             <GradientBg />
             <GradientBg />
           </OverflowWrapper>
           </OverflowWrapper>
@@ -154,48 +227,16 @@ export default class Register extends Component<PropsType, StateType> {
             <Logo src={logo} />
             <Logo src={logo} />
             <Prompt>Sign up for Porter</Prompt>
             <Prompt>Sign up for Porter</Prompt>
             {this.renderGithubSection()}
             {this.renderGithubSection()}
+            {this.renderGoogleSection()}
+            {(this.state.hasGithub || this.state.hasGoogle) &&
+            this.state.hasBasic ? (
+              <OrWrapper>
+                <Line />
+                <Or>or</Or>
+              </OrWrapper>
+            ) : null}
             <DarkMatter />
             <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>
             <Helper>
               Have an account?
               Have an account?
               <Link href="/login">Sign in</Link>
               <Link href="/login">Sign in</Link>
@@ -263,7 +304,12 @@ const IconWrapper = styled.div`
 
 
 const Icon = styled.img`
 const Icon = styled.img`
   height: 18px;
   height: 18px;
-  margin-right: 20px;
+  margin: 14px;
+`;
+
+const StyledGoogleIcon = styled(GoogleIcon)`
+  width: 38px;
+  height: 38px;
 `;
 `;
 
 
 const OAuthButton = styled.div`
 const OAuthButton = styled.div`
@@ -278,6 +324,8 @@ const OAuthButton = styled.div`
   user-select: none;
   user-select: none;
   font-weight: 500;
   font-weight: 500;
   font-size: 13px;
   font-size: 13px;
+  margin: 10px 0;
+  overflow: hidden;
   :hover {
   :hover {
     background: #ffffffdd;
     background: #ffffffdd;
   }
   }
@@ -405,11 +453,11 @@ const FormWrapper = styled.div`
 
 
 const GradientBg = styled.div`
 const GradientBg = styled.div`
   background: linear-gradient(#8ce1ff, #a59eff, #fba8ff);
   background: linear-gradient(#8ce1ff, #a59eff, #fba8ff);
-  width: 180%;
-  height: 180%;
+  width: 200%;
+  height: 200%;
   position: absolute;
   position: absolute;
-  top: -40%;
-  left: -40%;
+  top: -50%;
+  left: -50%;
   animation: flip 6s infinite linear;
   animation: flip 6s infinite linear;
   @keyframes flip {
   @keyframes flip {
     from {
     from {
@@ -423,7 +471,8 @@ const GradientBg = styled.div`
 
 
 const LoginPanel = styled.div`
 const LoginPanel = styled.div`
   width: 330px;
   width: 330px;
-  height: 500px;
+  height: ${(props: { numOAuth: number; hasBasic: boolean }) =>
+    270 + +props.hasBasic * 180 + props.numOAuth * 50}px;
   background: white;
   background: white;
   margin-top: -20px;
   margin-top: -20px;
   border-radius: 10px;
   border-radius: 10px;

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

@@ -108,9 +108,10 @@ export default class ChartList extends Component<PropsType, StateType> {
 
 
   setupWebsocket = (kind: string) => {
   setupWebsocket = (kind: string) => {
     let { currentCluster, currentProject } = this.context;
     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(
     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 = () => {
     ws.onopen = () => {
       console.log("connected to websocket");
       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) => {
   setupWebsocket = (kind: string, chart: ChartType) => {
     let { currentCluster, currentProject } = this.context;
     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(
     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 = () => {
     ws.onopen = () => {
       console.log("connected to websocket");
       console.log("connected to websocket");
@@ -276,8 +276,19 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
         });
         });
       })
       })
       .catch((err) => {
       .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", {
         window.analytics.track("Failed to Upgrade Chart", {
           chart: this.state.currentChart.name,
           chart: this.state.currentChart.name,
           values: valuesYaml,
           values: valuesYaml,
@@ -328,8 +339,20 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
         cb && cb();
         cb && cb();
       })
       })
       .catch((err) => {
       .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", {
         window.analytics.track("Failed to Upgrade Chart", {
           chart: this.state.currentChart.name,
           chart: this.state.currentChart.name,
           values: valuesYaml,
           values: valuesYaml,

+ 64 - 47
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)
   // Retrieve full chart data (includes form and values)
-  getChartData = (chart: ChartType) => {
+  getChartData = (chart: ChartType, revision: number) => {
     let { currentProject } = this.context;
     let { currentProject } = this.context;
     let { currentCluster, currentChart } = this.props;
     let { currentCluster, currentChart } = this.props;
 
 
@@ -77,12 +77,15 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
         },
         },
         {
         {
           name: chart.name,
           name: chart.name,
-          revision: chart.version,
+          revision: revision,
           id: currentProject.id,
           id: currentProject.id,
         }
         }
       )
       )
       .then((res) => {
       .then((res) => {
         let image = res.data?.config?.image?.repository;
         let image = res.data?.config?.image?.repository;
+        let tag = res.data?.config?.image?.tag.toString();
+        let newestImage = tag ? image + ":" + tag : image;
+
         if (
         if (
           (image === "porterdev/hello-porter-job" ||
           (image === "porterdev/hello-porter-job" ||
             image === "public.ecr.aws/o1j4x7p4/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,
               currentChart: res.data,
               loading: false,
               loading: false,
               imageIsPlaceholder: true,
               imageIsPlaceholder: true,
-              newestImage: image,
+              newestImage: newestImage,
             },
             },
             () => {
             () => {
               this.updateTabs();
               this.updateTabs();
@@ -101,7 +104,11 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
           );
           );
         } else {
         } else {
           this.setState(
           this.setState(
-            { currentChart: res.data, loading: false, newestImage: image },
+            {
+              currentChart: res.data,
+              loading: false,
+              newestImage: newestImage,
+            },
             () => {
             () => {
               this.updateTabs();
               this.updateTabs();
             }
             }
@@ -111,7 +118,8 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
       .catch(console.log);
       .catch(console.log);
   };
   };
 
 
-  refreshChart = () => this.getChartData(this.state.currentChart);
+  refreshChart = (revision: number) =>
+    this.getChartData(this.state.currentChart, revision);
 
 
   mergeNewJob = (newJob: any) => {
   mergeNewJob = (newJob: any) => {
     let jobs = this.state.jobs;
     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 chartVersion = `${chart.chart.metadata.name}-${chart.chart.metadata.version}`;
 
 
     let { currentCluster, currentProject } = this.context;
     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(
     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 = () => {
     ws.onopen = () => {
       console.log("connected to websocket");
       console.log("connected to websocket");
@@ -181,13 +189,13 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
   };
   };
 
 
   setupCronJobWebsocket = (chart: ChartType) => {
   setupCronJobWebsocket = (chart: ChartType) => {
-    let releaseName = chart.name
-    let releaseNamespace = chart.namespace
+    let releaseName = chart.name;
+    let releaseNamespace = chart.namespace;
 
 
     let { currentCluster, currentProject } = this.context;
     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(
     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 = () => {
     ws.onopen = () => {
       console.log("connected to websocket");
       console.log("connected to websocket");
@@ -204,20 +212,20 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
         this.state.imageIsPlaceholder
         this.state.imageIsPlaceholder
       ) {
       ) {
         // filter job belonging to chart
         // filter job belonging to chart
-        let relNameAnn = event.Object?.metadata?.annotations["meta.helm.sh/release-name"]
-        let relNamespaceAnn = event.Object?.metadata?.annotations["meta.helm.sh/release-namespace"]
-        
+        let relNameAnn =
+          event.Object?.metadata?.annotations["meta.helm.sh/release-name"];
+        let relNamespaceAnn =
+          event.Object?.metadata?.annotations["meta.helm.sh/release-namespace"];
+
         if (
         if (
           relNameAnn &&
           relNameAnn &&
           relNamespaceAnn &&
           relNamespaceAnn &&
           releaseName == relNameAnn &&
           releaseName == relNameAnn &&
           releaseNamespace == relNamespaceAnn
           releaseNamespace == relNamespaceAnn
         ) {
         ) {
-          console.log("belonged to chart");
           let newestImage =
           let newestImage =
             event.Object?.spec?.jobTemplate?.spec?.template?.spec?.containers[0]
             event.Object?.spec?.jobTemplate?.spec?.template?.spec?.containers[0]
               ?.image;
               ?.image;
-          console.log("newest image", newestImage)
           if (
           if (
             newestImage &&
             newestImage &&
             newestImage !== "porterdev/hello-porter-job" &&
             newestImage !== "porterdev/hello-porter-job" &&
@@ -225,7 +233,6 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
             newestImage !== "public.ecr.aws/o1j4x7p4/hello-porter-job" &&
             newestImage !== "public.ecr.aws/o1j4x7p4/hello-porter-job" &&
             newestImage !== "public.ecr.aws/o1j4x7p4/hello-porter-job:latest"
             newestImage !== "public.ecr.aws/o1j4x7p4/hello-porter-job:latest"
           ) {
           ) {
-            console.log("setting image placeholder to false");
             this.setState({ newestImage, imageIsPlaceholder: false });
             this.setState({ newestImage, imageIsPlaceholder: false });
           }
           }
         }
         }
@@ -255,15 +262,15 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
       let imageUrl = this.state.newestImage;
       let imageUrl = this.state.newestImage;
       let tag = null;
       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) {
+        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.repository", imageUrl);
         _.set(values, "image.tag", tag);
         _.set(values, "image.tag", tag);
       }
       }
@@ -281,26 +288,28 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
       }
       }
 
 
       let imageUrl = this.state.newestImage;
       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) {
+        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.repository", imageUrl);
-        _.set(values, "image.tag", tag);
+        _.set(values, "image.tag", `${tag}`);
       }
       }
 
 
       // Weave in preexisting values and convert to yaml
       // 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
     api
@@ -319,12 +328,21 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
       )
       )
       .then((res) => {
       .then((res) => {
         this.setState({ saveValuesStatus: "successful" });
         this.setState({ saveValuesStatus: "successful" });
-        this.refreshChart();
+        this.refreshChart(0);
       })
       })
       .catch((err) => {
       .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);
       });
       });
   };
   };
 
 
@@ -366,7 +384,6 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
       newestImage !== "public.ecr.aws/o1j4x7p4/hello-porter-job" &&
       newestImage !== "public.ecr.aws/o1j4x7p4/hello-porter-job" &&
       newestImage !== "public.ecr.aws/o1j4x7p4/hello-porter-job:latest"
       newestImage !== "public.ecr.aws/o1j4x7p4/hello-porter-job:latest"
     ) {
     ) {
-      console.log("set to false on sorting")
       this.setState({ jobs, newestImage, imageIsPlaceholder: false });
       this.setState({ jobs, newestImage, imageIsPlaceholder: false });
     } else {
     } else {
       this.setState({ jobs });
       this.setState({ jobs });
@@ -404,7 +421,7 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
         return (
         return (
           <SettingsSection
           <SettingsSection
             currentChart={this.state.currentChart}
             currentChart={this.state.currentChart}
-            refreshChart={this.refreshChart}
+            refreshChart={() => this.refreshChart(0)}
             setShowDeleteOverlay={(x: boolean) =>
             setShowDeleteOverlay={(x: boolean) =>
               this.setState({ showDeleteOverlay: x })
               this.setState({ showDeleteOverlay: x })
             }
             }
@@ -473,7 +490,7 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
       chart: currentChart.name,
       chart: currentChart.name,
     });
     });
 
 
-    this.getChartData(currentChart);
+    this.getChartData(currentChart, currentChart.version);
     this.getJobs(currentChart);
     this.getJobs(currentChart);
     this.setupJobWebsocket(currentChart);
     this.setupJobWebsocket(currentChart);
     this.setupCronJobWebsocket(currentChart);
     this.setupCronJobWebsocket(currentChart);
@@ -537,7 +554,7 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
               </Title>
               </Title>
               <InfoWrapper>
               <InfoWrapper>
                 <LastDeployed>
                 <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)}
                   {" " + this.readableDate(chart.info.last_deployed)}
                 </LastDeployed>
                 </LastDeployed>
               </InfoWrapper>
               </InfoWrapper>

+ 13 - 3
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -83,7 +83,7 @@ export default class SettingsSection extends Component<PropsType, StateType> {
 
 
   redeployWithNewImage = (img: string, tag: string) => {
   redeployWithNewImage = (img: string, tag: string) => {
     this.setState({ saveValuesStatus: "loading" });
     this.setState({ saveValuesStatus: "loading" });
-    let { currentCluster, currentProject } = this.context;
+    let { currentCluster, currentProject, setCurrentError } = this.context;
 
 
     // If tag is explicitly declared, parse tag
     // If tag is explicitly declared, parse tag
     let imgSplits = img.split(":");
     let imgSplits = img.split(":");
@@ -131,8 +131,18 @@ export default class SettingsSection extends Component<PropsType, StateType> {
         this.props.refreshChart();
         this.props.refreshChart();
       })
       })
       .catch((err) => {
       .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);
       });
       });
   };
   };
 
 

+ 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();
         this.props.refreshChart();
       })
       })
       .catch((err) => {
       .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);
       });
       });
   };
   };
 
 

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

@@ -14,6 +14,7 @@ type StateType = {
   logs: Anser.AnserJsonEntry[][];
   logs: Anser.AnserJsonEntry[][];
   ws: any;
   ws: any;
   scroll: boolean;
   scroll: boolean;
+  currentTab: string;
 };
 };
 
 
 export default class Logs extends Component<PropsType, StateType> {
 export default class Logs extends Component<PropsType, StateType> {
@@ -21,6 +22,7 @@ export default class Logs extends Component<PropsType, StateType> {
     logs: [] as Anser.AnserJsonEntry[][],
     logs: [] as Anser.AnserJsonEntry[][],
     ws: null as any,
     ws: null as any,
     scroll: true,
     scroll: true,
+    currentTab: "Application",
   };
   };
 
 
   ws = null as any;
   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 = () => {
   setupWebsocket = () => {
     let { currentCluster, currentProject } = this.context;
     let { currentCluster, currentProject } = this.context;
     let { selectedPod } = this.props;
     let { selectedPod } = this.props;
     if (!selectedPod?.metadata?.name) return;
     if (!selectedPod?.metadata?.name) return;
-    let protocol = process.env.NODE_ENV == "production" ? "wss" : "ws";
+    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
     this.ws = new WebSocket(
     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 = () => {};
     this.ws.onopen = () => {};
@@ -157,24 +124,33 @@ export default class Logs extends Component<PropsType, StateType> {
   };
   };
 
 
   refreshLogs = () => {
   refreshLogs = () => {
-    if (this.ws) {
+    let { selectedPod } = this.props;
+    if (this.ws && this.state.currentTab == "Application") {
       this.ws.close();
       this.ws.close();
       this.ws = null;
       this.ws = null;
       this.setState({ logs: [] });
       this.setState({ logs: [] });
       this.setupWebsocket();
       this.setupWebsocket();
     }
     }
+    this.retrieveEvents(selectedPod);
   };
   };
 
 
-  componentDidMount() {
-    let { selectedPod } = this.props;
-    let status = this.getPodStatus(selectedPod?.status);
-    console.log("STATUS", selectedPod?.status, 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
     api
       .getPodEvents(
       .getPodEvents(
         "<token>",
         "<token>",
@@ -205,6 +181,18 @@ export default class Logs extends Component<PropsType, StateType> {
       .catch((err) => {
       .catch((err) => {
         console.log(err);
         console.log(err);
       });
       });
+  };
+
+  componentDidMount() {
+    let { selectedPod } = this.props;
+
+    if (this.state.currentTab == "Application") {
+      this.setupWebsocket();
+      this.scrollToBottom(false);
+      return;
+    }
+
+    this.retrieveEvents(selectedPod);
   }
   }
 
 
   componentWillUnmount() {
   componentWillUnmount() {
@@ -218,6 +206,24 @@ export default class Logs extends Component<PropsType, StateType> {
       return (
       return (
         <LogStreamAlt>
         <LogStreamAlt>
           <Wrapper ref={this.parentRef}>{this.renderLogs()}</Wrapper>
           <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>
         </LogStreamAlt>
       );
       );
     }
     }
@@ -225,6 +231,24 @@ export default class Logs extends Component<PropsType, StateType> {
     return (
     return (
       <LogStream>
       <LogStream>
         <Wrapper ref={this.parentRef}>{this.renderLogs()}</Wrapper>
         <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>
         <Options>
           <Scroll
           <Scroll
             onClick={() => {
             onClick={() => {
@@ -291,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`
 const Refresh = styled.div`
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
@@ -310,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`
 const Options = styled.div`
   width: 100%;
   width: 100%;
   height: 25px;
   height: 25px;

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

@@ -203,10 +203,7 @@ export default class Templates extends Component<PropsType, StateType> {
         <TemplatesWrapper>
         <TemplatesWrapper>
           <TitleSection>
           <TitleSection>
             <Title>Launch</Title>
             <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>
               <i className="material-icons">help_outline</i>
             </a>
             </a>
           </TitleSection>
           </TitleSection>

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

@@ -516,7 +516,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
             To configure this chart through Porter,
             To configure this chart through Porter,
             <Link
             <Link
               target="_blank"
               target="_blank"
-              href="https://docs.getporter.dev/docs/porter-templates"
+              href="https://docs.getporter.dev/docs/add-ons"
             >
             >
               refer to our docs
               refer to our docs
             </Link>
             </Link>

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

@@ -78,7 +78,7 @@ class LaunchFlow extends Component<PropsType, StateType> {
   };
   };
 
 
   createGHAction = (chartName: string, chartNamespace: string, env?: any) => {
   createGHAction = (chartName: string, chartNamespace: string, env?: any) => {
-    let { currentProject, currentCluster } = this.context;
+    let { currentProject, currentCluster, setCurrentError } = this.context;
     let {
     let {
       actionConfig,
       actionConfig,
       branch,
       branch,
@@ -123,6 +123,8 @@ class LaunchFlow extends Component<PropsType, StateType> {
         this.setState({
         this.setState({
           saveValuesStatus: `Could not create GitHub Action: ${err}`,
           saveValuesStatus: `Could not create GitHub Action: ${err}`,
         });
         });
+
+        setCurrentError(err);
       });
       });
   };
   };
 
 
@@ -179,7 +181,7 @@ class LaunchFlow extends Component<PropsType, StateType> {
           err = parsedErr;
           err = parsedErr;
         }
         }
         this.setState({
         this.setState({
-          saveValuesStatus: `Could not deploy template: ${err}`,
+          saveValuesStatus: parsedErr,
         });
         });
         setCurrentError(err.response.data.errors[0]);
         setCurrentError(err.response.data.errors[0]);
         window.analytics.track("Failed to Deploy Add-on", {
         window.analytics.track("Failed to Deploy Add-on", {
@@ -192,7 +194,7 @@ class LaunchFlow extends Component<PropsType, StateType> {
   };
   };
 
 
   onSubmit = async (rawValues: any) => {
   onSubmit = async (rawValues: any) => {
-    let { currentCluster, currentProject } = this.context;
+    let { currentCluster, currentProject, setCurrentError } = this.context;
     let {
     let {
       selectedNamespace,
       selectedNamespace,
       templateName,
       templateName,
@@ -278,6 +280,8 @@ class LaunchFlow extends Component<PropsType, StateType> {
               this.setState({
               this.setState({
                 saveValuesStatus: `Could not create subdomain: ${err}`,
                 saveValuesStatus: `Could not create subdomain: ${err}`,
               });
               });
+
+              setCurrentError(err);
             });
             });
         });
         });
 
 
@@ -332,6 +336,7 @@ class LaunchFlow extends Component<PropsType, StateType> {
         this.setState({
         this.setState({
           saveValuesStatus: `Could not deploy template: ${err}`,
           saveValuesStatus: `Could not deploy template: ${err}`,
         });
         });
+        setCurrentError(err);
       });
       });
   };
   };
 
 

+ 1 - 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,
             To configure this chart through Porter,
             <Link
             <Link
               target="_blank"
               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
               refer to our docs
             </Link>
             </Link>

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

@@ -29,7 +29,7 @@ export default class InviteList extends Component<PropsType, StateType> {
     invites: [] as InviteType[],
     invites: [] as InviteType[],
     email: "",
     email: "",
     invalidEmail: false,
     invalidEmail: false,
-    isHTTPS: process.env.API_SERVER === "dashboard.getporter.dev",
+    isHTTPS: window.location.protocol === "https:",
   };
   };
 
 
   componentDidMount() {
   componentDidMount() {
@@ -117,7 +117,7 @@ export default class InviteList extends Component<PropsType, StateType> {
   getInviteUrl = (index: number) => {
   getInviteUrl = (index: number) => {
     let { currentProject } = this.context;
     let { currentProject } = this.context;
     return `${this.state.isHTTPS ? "https://" : ""}${
     return `${this.state.isHTTPS ? "https://" : ""}${
-      process.env.API_SERVER
+      window.location.host
     }/api/projects/${currentProject.id}/invites/${
     }/api/projects/${currentProject.id}/invites/${
       this.state.invites[index].token
       this.state.invites[index].token
     }`;
     }`;
@@ -174,7 +174,7 @@ export default class InviteList extends Component<PropsType, StateType> {
                     disabled={true}
                     disabled={true}
                     type="string"
                     type="string"
                     value={`${this.state.isHTTPS ? "https://" : ""}${
                     value={`${this.state.isHTTPS ? "https://" : ""}${
-                      process.env.API_SERVER
+                      window.location.host
                     }/api/projects/${currentProject.id}/invites/${
                     }/api/projects/${currentProject.id}/invites/${
                       this.state.invites[i].token
                       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;
     if (!selectedInfra) return;
 
 
-    let protocol = process.env.NODE_ENV == "production" ? "wss" : "ws";
+    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
     this.ws = new WebSocket(
     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();
     this.setupWebsocket();

+ 28 - 0
docs/guides/template-versioning-and-upgrades.md

@@ -0,0 +1,28 @@
+
+> 📘
+>
+> **Note:** this functionality was introduced in [Porter release 0.3.0](https://docs.getporter.dev/changelog/v030-tuesday-18-may-2021). By default, all charts deployed before this release were versioned with `v0.1.0`. If you experience any issues upgrading from `v0.1.0`, it is recommended that you **re-deploy the service with the latest version from the Launch tab**.
+
+
+# Deploying a Specific Template Version
+
+Every application template that you deploy on Porter has a specific version. You will be given the option to select the version when you are launching the template:
+
+![Launch template version](https://files.readme.io/64987b5-Screen_Shot_2021-05-18_at_5.47.38_PM.png "Screen Shot 2021-05-18 at 5.47.38 PM.png")
+
+# How to Upgrade
+
+After deploying the template, you will be notified if a chart upgrade is available:
+
+![Chart upgrade available](https://files.readme.io/34bb4a0-Screen_Shot_2021-05-18_at_5.45.42_PM.png "Screen Shot 2021-05-18 at 5.45.42 PM.png")
+
+This upgrade will be of three different types, following [semantic versioning guidelines](https://semver.org/):
+- **Patch upgrade** (ex. `v0.20.0 -> v0.20.1`): upgrades with backwards-compatible bug fixes. These upgrades will not require any configuration changes: you will simply be asked to upgrade the chart, and the upgrade will occur after confirmation. 
+- **Minor upgrade** (ex. `v0.20.0 -> v0.21.0`): new features with backwards-compatible configuration. These upgrades will not require any configuration changes: you will simply be asked to upgrade the chart, and the upgrade will occur after confirmation. 
+- **Major upgrade** (ex. `v0.20.0 -> v1.0.0`): substantial new feature sets that **may** require configuration changes.  These upgrades will link to docs describing the upgrade process: we will try our best to make all major upgrades as backwards-compatible as possible, with any backwards incompatibilities documented clearly.
+
+# Reverting an Upgrade
+
+If an upgrade causes unexpected behavior or introduces a bug, you will be able to revert this upgrade immediately from the Porter dashboard. You can do this by clicking into the chart, expanding the list of revisions, and clicking on the "Revert" button to roll back the version:
+
+![Revert to revision](https://files.readme.io/10971ce-Screen_Shot_2021-05-18_at_5.49.42_PM.png "Screen Shot 2021-05-18 at 5.49.42 PM.png")

+ 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.Username,
 		conf.Password,
 		conf.Password,
 		conf.Port,
 		conf.Port,
 		conf.Host,
 		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,
 		FullSaveAssociations: true,
 	})
 	})
 
 
@@ -40,7 +60,9 @@ func New(conf *config.DBConf) (*gorm.DB, error) {
 	if err != nil {
 	if err != nil {
 		for {
 		for {
 			time.Sleep(timeout)
 			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 {
 			if retryCount > 3 {
 				return nil, err
 				return nil, err

+ 19 - 6
internal/config/config.go

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

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

@@ -17,6 +17,8 @@ import (
 )
 )
 
 
 type GithubActions struct {
 type GithubActions struct {
+	ServerURL string
+
 	GitIntegration *models.GitRepo
 	GitIntegration *models.GitRepo
 	GitRepoName    string
 	GitRepoName    string
 	GitRepoOwner   string
 	GitRepoOwner   string
@@ -157,7 +159,7 @@ func (g *GithubActions) GetGithubActionYAML() ([]byte, error) {
 	gaSteps := []GithubActionYAMLStep{
 	gaSteps := []GithubActionYAMLStep{
 		getCheckoutCodeStep(),
 		getCheckoutCodeStep(),
 		getDownloadPorterStep(),
 		getDownloadPorterStep(),
-		getConfigurePorterStep(g.getPorterTokenSecretName()),
+		getConfigurePorterStep(g.ServerURL, g.getPorterTokenSecretName()),
 	}
 	}
 
 
 	if g.DockerFilePath == "" {
 	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, 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
 	branch := g.GitBranch
 
 

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

@@ -31,15 +31,16 @@ func getDownloadPorterStep() GithubActionYAMLStep {
 }
 }
 
 
 const configure string = `
 const configure string = `
+sudo porter config set-host %s
 sudo porter auth login --token ${{secrets.%s}}
 sudo porter auth login --token ${{secrets.%s}}
 sudo porter docker configure
 sudo porter docker configure
 `
 `
 
 
-func getConfigurePorterStep(porterTokenSecretName string) GithubActionYAMLStep {
+func getConfigurePorterStep(serverURL, porterTokenSecretName string) GithubActionYAMLStep {
 	return GithubActionYAMLStep{
 	return GithubActionYAMLStep{
 		Name: "Configure Porter",
 		Name: "Configure Porter",
 		ID:   "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 = `
 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{
 	return GithubActionYAMLStep{
 		Name: "Deploy on Porter",
 		Name: "Deploy on Porter",
 		ID:   "deploy_porter",
 		ID:   "deploy_porter",
-		Run:  fmt.Sprintf(deployPorter, webhookTokenSecretName, repoURL),
+		Run:  fmt.Sprintf(deployPorter, serverURL, webhookTokenSecretName),
 	}
 	}
 }
 }

+ 10 - 0
internal/kubernetes/config.go

@@ -66,6 +66,16 @@ func GetAgentOutOfClusterConfig(conf *OutOfClusterConfig) (*Agent, error) {
 	return &Agent{conf, clientset}, nil
 	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
 // GetAgentInClusterConfig uses the service account that kubernetes
 // gives to pods to connect
 // gives to pods to connect
 func GetAgentInClusterConfig() (*Agent, error) {
 func GetAgentInClusterConfig() (*Agent, error) {

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

@@ -11,6 +11,7 @@ type OAuthIntegrationClient string
 const (
 const (
 	OAuthGithub       OAuthIntegrationClient = "github"
 	OAuthGithub       OAuthIntegrationClient = "github"
 	OAuthDigitalOcean OAuthIntegrationClient = "do"
 	OAuthDigitalOcean OAuthIntegrationClient = "do"
+	OAuthGoogle       OAuthIntegrationClient = "google"
 )
 )
 
 
 // OAuthIntegration is an auth mechanism that uses oauth
 // 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)
 	// The github user id used for login (optional)
 	GithubUserID int64
 	GithubUserID int64
+	GoogleUserID string
 }
 }
 
 
 // UserExternal represents the User type that is sent over REST
 // 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 {
 func CreateRandomState() string {
 	b := make([]byte, 16)
 	b := make([]byte, 16)
 	rand.Read(b)
 	rand.Read(b)

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

@@ -53,6 +53,15 @@ func (repo *UserRepository) ReadUserByGithubUserID(id int64) (*models.User, erro
 	return user, nil
 	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
 // UpdateUser modifies an existing User in the database
 func (repo *UserRepository) UpdateUser(user *models.User) (*models.User, error) {
 func (repo *UserRepository) UpdateUser(user *models.User) (*models.User, error) {
 	if err := repo.db.Save(user).Error; err != nil {
 	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)
 		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
 	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
 // UpdateUser modifies an existing User in the database
 func (repo *UserRepository) UpdateUser(user *models.User) (*models.User, error) {
 func (repo *UserRepository) UpdateUser(user *models.User) (*models.User, error) {
 	if !repo.canQuery {
 	if !repo.canQuery {

+ 1 - 0
internal/repository/user.go

@@ -14,6 +14,7 @@ type UserRepository interface {
 	ReadUser(id uint) (*models.User, error)
 	ReadUser(id uint) (*models.User, error)
 	ReadUserByEmail(email string) (*models.User, error)
 	ReadUserByEmail(email string) (*models.User, error)
 	ReadUserByGithubUserID(id int64) (*models.User, error)
 	ReadUserByGithubUserID(id int64) (*models.User, error)
+	ReadUserByGoogleUserID(id string) (*models.User, error)
 	UpdateUser(user *models.User) (*models.User, error)
 	UpdateUser(user *models.User) (*models.User, error)
 	DeleteUser(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"
 	"github.com/porter-dev/porter/internal/repository"
 	memory "github.com/porter-dev/porter/internal/repository/memory"
 	memory "github.com/porter-dev/porter/internal/repository/memory"
 	"github.com/porter-dev/porter/internal/validator"
 	"github.com/porter-dev/porter/internal/validator"
-	"helm.sh/helm/v3/pkg/storage"
 	segment "gopkg.in/segmentio/analytics-go.v3"
 	segment "gopkg.in/segmentio/analytics-go.v3"
+	"helm.sh/helm/v3/pkg/storage"
 
 
 	"github.com/porter-dev/porter/internal/config"
 	"github.com/porter-dev/porter/internal/config"
 )
 )
@@ -42,7 +42,7 @@ type AppConfig struct {
 	ServerConf config.ServerConf
 	ServerConf config.ServerConf
 	RedisConf  *config.RedisConf
 	RedisConf  *config.RedisConf
 	DBConf     config.DBConf
 	DBConf     config.DBConf
-	CapConf config.CapConf
+	CapConf    config.CapConf
 
 
 	// TestAgents if API is in testing mode
 	// TestAgents if API is in testing mode
 	TestAgents *TestAgents
 	TestAgents *TestAgents
@@ -66,6 +66,9 @@ type App struct {
 	// agents exposed for testing
 	// agents exposed for testing
 	TestAgents *TestAgents
 	TestAgents *TestAgents
 
 
+	// An in-cluster agent if service is running in cluster
+	InClusterAgent *kubernetes.Agent
+
 	// redis client for redis connection
 	// redis client for redis connection
 	RedisConf *config.RedisConf
 	RedisConf *config.RedisConf
 
 
@@ -73,20 +76,31 @@ type App struct {
 	DBConf config.DBConf
 	DBConf config.DBConf
 
 
 	// config for capabilities
 	// config for capabilities
-	CapConf config.CapConf
+	Capabilities *AppCapabilities
 
 
 	// oauth-specific clients
 	// oauth-specific clients
 	GithubUserConf    *oauth2.Config
 	GithubUserConf    *oauth2.Config
 	GithubProjectConf *oauth2.Config
 	GithubProjectConf *oauth2.Config
 	DOConf            *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
 	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
 // New returns a new App instance
 func New(conf *AppConfig) (*App, error) {
 func New(conf *AppConfig) (*App, error) {
 	// create a new validator and translator
 	// create a new validator and translator
@@ -101,16 +115,16 @@ func New(conf *AppConfig) (*App, error) {
 	}
 	}
 
 
 	app := &App{
 	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
 	// if repository not specified, default to in-memory
@@ -127,8 +141,25 @@ func New(conf *AppConfig) (*App, error) {
 
 
 	app.Store = store
 	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 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{
 		app.GithubUserConf = oauth.NewGithubClient(&oauth.Config{
 			ClientID:     sc.GithubClientID,
 			ClientID:     sc.GithubClientID,
 			ClientSecret: sc.GithubClientSecret,
 			ClientSecret: sc.GithubClientSecret,
@@ -142,9 +173,26 @@ func New(conf *AppConfig) (*App, error) {
 			Scopes:       []string{"repo", "read:user", "workflow"},
 			Scopes:       []string{"repo", "read:user", "workflow"},
 			BaseURL:      sc.ServerURL,
 			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{
 		app.DOConf = oauth.NewDigitalOceanClient(&oauth.Config{
 			ClientID:     sc.DOClientID,
 			ClientID:     sc.DOClientID,
 			ClientSecret: sc.DOClientSecret,
 			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{
 	app.tokenConf = &token.TokenGeneratorConf{
 		TokenSecret: conf.ServerConf.TokenGeneratorSecret,
 		TokenSecret: conf.ServerConf.TokenGeneratorSecret,
 	}
 	}

+ 1 - 15
server/api/capability_handler.go

@@ -5,23 +5,9 @@ import (
 	"net/http"
 	"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
 // HandleGetCapabilities gets the capabilities of the server
 func (app *App) HandleGetCapabilities(w http.ResponseWriter, r *http.Request) {
 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)
 		app.handleErrorFormDecoding(err, ErrK8sDecode, w)
 		return
 		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{
 				gaRunner := &actions.GithubActions{
+					ServerURL:      app.ServerConf.ServerURL,
 					GitIntegration: gr,
 					GitIntegration: gr,
 					GitRepoName:    repoSplit[1],
 					GitRepoName:    repoSplit[1],
 					GitRepoOwner:   repoSplit[0],
 					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
 		return
 	}
 	}
 
 
-	// launch provisioning destruction pod
-	inClusterAgent, err := kubernetes.GetAgentInClusterConfig()
-
-	if err != nil {
-		app.handleErrorDataRead(err, w)
-		return
-	}
-
 	_record := domain.DNSRecord(*record)
 	_record := domain.DNSRecord(*record)
 
 
-	err = _record.CreateDomain(inClusterAgent.Clientset)
+	err = _record.CreateDomain(app.InClusterAgent.Clientset)
 
 
 	if err != nil {
 	if err != nil {
 		app.handleErrorInternal(err, w)
 		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
 	// create the commit in the git repo
 	gaRunner := &actions.GithubActions{
 	gaRunner := &actions.GithubActions{
+		ServerURL:      app.ServerConf.ServerURL,
 		GitIntegration: gr,
 		GitIntegration: gr,
 		GitRepoName:    repoSplit[1],
 		GitRepoName:    repoSplit[1],
 		GitRepoOwner:   repoSplit[0],
 		GitRepoOwner:   repoSplit[0],

+ 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
 		return
 	}
 	}
 
 
-	// create a new agent
-	agent, err := kubernetes.GetAgentInClusterConfig()
-
-	if err != nil {
-		app.handleErrorDataRead(err, w)
-		return
-	}
-
 	form := &forms.CreateTestInfra{
 	form := &forms.CreateTestInfra{
 		ProjectID: uint(projID),
 		ProjectID: uint(projID),
 	}
 	}
@@ -59,7 +51,7 @@ func (app *App) HandleProvisionTestInfra(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
-	_, err = agent.ProvisionTest(
+	_, err = app.InClusterAgent.ProvisionTest(
 		uint(projID),
 		uint(projID),
 		infra,
 		infra,
 		*app.Repo,
 		*app.Repo,
@@ -199,17 +191,7 @@ func (app *App) HandleProvisionAWSECRInfra(w http.ResponseWriter, r *http.Reques
 	}
 	}
 
 
 	// launch provisioning pod
 	// 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),
 		uint(projID),
 		awsInt,
 		awsInt,
 		form.ECRName,
 		form.ECRName,
@@ -282,16 +264,6 @@ func (app *App) HandleDestroyAWSECRInfra(w http.ResponseWriter, r *http.Request)
 	}
 	}
 
 
 	// launch provisioning destruction pod
 	// 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
 	// mark infra for deletion
 	infra.Status = models.StatusDestroying
 	infra.Status = models.StatusDestroying
 	infra, err = app.Repo.Infra.UpdateInfra(infra)
 	infra, err = app.Repo.Infra.UpdateInfra(infra)
@@ -301,7 +273,7 @@ func (app *App) HandleDestroyAWSECRInfra(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
-	_, err = agent.ProvisionECR(
+	_, err = app.InClusterAgent.ProvisionECR(
 		infra.ProjectID,
 		infra.ProjectID,
 		awsInt,
 		awsInt,
 		form.ECRName,
 		form.ECRName,
@@ -375,17 +347,7 @@ func (app *App) HandleProvisionAWSEKSInfra(w http.ResponseWriter, r *http.Reques
 	}
 	}
 
 
 	// launch provisioning pod
 	// 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),
 		uint(projID),
 		awsInt,
 		awsInt,
 		form.EKSName,
 		form.EKSName,
@@ -459,16 +421,6 @@ func (app *App) HandleDestroyAWSEKSInfra(w http.ResponseWriter, r *http.Request)
 	}
 	}
 
 
 	// launch provisioning destruction pod
 	// 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
 	// mark infra for deletion
 	infra.Status = models.StatusDestroying
 	infra.Status = models.StatusDestroying
 	infra, err = app.Repo.Infra.UpdateInfra(infra)
 	infra, err = app.Repo.Infra.UpdateInfra(infra)
@@ -478,7 +430,7 @@ func (app *App) HandleDestroyAWSEKSInfra(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
-	_, err = agent.ProvisionEKS(
+	_, err = app.InClusterAgent.ProvisionEKS(
 		infra.ProjectID,
 		infra.ProjectID,
 		awsInt,
 		awsInt,
 		form.EKSName,
 		form.EKSName,
@@ -553,17 +505,7 @@ func (app *App) HandleProvisionGCPGCRInfra(w http.ResponseWriter, r *http.Reques
 	}
 	}
 
 
 	// launch provisioning pod
 	// 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),
 		uint(projID),
 		gcpInt,
 		gcpInt,
 		*app.Repo,
 		*app.Repo,
@@ -646,17 +588,7 @@ func (app *App) HandleProvisionGCPGKEInfra(w http.ResponseWriter, r *http.Reques
 	}
 	}
 
 
 	// launch provisioning pod
 	// 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),
 		uint(projID),
 		gcpInt,
 		gcpInt,
 		form.GKEName,
 		form.GKEName,
@@ -729,16 +661,6 @@ func (app *App) HandleDestroyGCPGKEInfra(w http.ResponseWriter, r *http.Request)
 	}
 	}
 
 
 	// launch provisioning destruction pod
 	// 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
 	// mark infra for deletion
 	infra.Status = models.StatusDestroying
 	infra.Status = models.StatusDestroying
 	infra, err = app.Repo.Infra.UpdateInfra(infra)
 	infra, err = app.Repo.Infra.UpdateInfra(infra)
@@ -748,7 +670,7 @@ func (app *App) HandleDestroyGCPGKEInfra(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
-	_, err = agent.ProvisionGKE(
+	_, err = app.InClusterAgent.ProvisionGKE(
 		infra.ProjectID,
 		infra.ProjectID,
 		gcpInt,
 		gcpInt,
 		form.GKEName,
 		form.GKEName,
@@ -866,17 +788,7 @@ func (app *App) HandleProvisionDODOCRInfra(w http.ResponseWriter, r *http.Reques
 	}
 	}
 
 
 	// launch provisioning pod
 	// 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),
 		uint(projID),
 		oauthInt,
 		oauthInt,
 		app.DOConf,
 		app.DOConf,
@@ -951,16 +863,6 @@ func (app *App) HandleDestroyDODOCRInfra(w http.ResponseWriter, r *http.Request)
 	}
 	}
 
 
 	// launch provisioning destruction pod
 	// 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
 	// mark infra for deletion
 	infra.Status = models.StatusDestroying
 	infra.Status = models.StatusDestroying
 	infra, err = app.Repo.Infra.UpdateInfra(infra)
 	infra, err = app.Repo.Infra.UpdateInfra(infra)
@@ -970,7 +872,7 @@ func (app *App) HandleDestroyDODOCRInfra(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
-	_, err = agent.ProvisionDOCR(
+	_, err = app.InClusterAgent.ProvisionDOCR(
 		infra.ProjectID,
 		infra.ProjectID,
 		oauthInt,
 		oauthInt,
 		app.DOConf,
 		app.DOConf,
@@ -1046,17 +948,7 @@ func (app *App) HandleProvisionDODOKSInfra(w http.ResponseWriter, r *http.Reques
 	}
 	}
 
 
 	// launch provisioning pod
 	// 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),
 		uint(projID),
 		oauthInt,
 		oauthInt,
 		app.DOConf,
 		app.DOConf,
@@ -1131,16 +1023,6 @@ func (app *App) HandleDestroyDODOKSInfra(w http.ResponseWriter, r *http.Request)
 	}
 	}
 
 
 	// launch provisioning destruction pod
 	// 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
 	// mark infra for deletion
 	infra.Status = models.StatusDestroying
 	infra.Status = models.StatusDestroying
 	infra, err = app.Repo.Infra.UpdateInfra(infra)
 	infra, err = app.Repo.Infra.UpdateInfra(infra)
@@ -1150,7 +1032,7 @@ func (app *App) HandleDestroyDODOKSInfra(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
-	_, err = agent.ProvisionDOKS(
+	_, err = app.InClusterAgent.ProvisionDOKS(
 		infra.ProjectID,
 		infra.ProjectID,
 		oauthInt,
 		oauthInt,
 		app.DOConf,
 		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 {
 	if err != nil {
 		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
 		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
 			Code:   ErrReleaseDeploy,
 			Code:   ErrReleaseDeploy,
-			Errors: []string{"error upgrading release " + err.Error()},
+			Errors: []string{err.Error()},
 		}, w)
 		}, w)
 
 
 		return
 		return
@@ -791,6 +791,7 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 				repoSplit := strings.Split(gitAction.GitRepo, "/")
 				repoSplit := strings.Split(gitAction.GitRepo, "/")
 
 
 				gaRunner := &actions.GithubActions{
 				gaRunner := &actions.GithubActions{
+					ServerURL:      app.ServerConf.ServerURL,
 					GitIntegration: gr,
 					GitIntegration: gr,
 					GitRepoName:    repoSplit[1],
 					GitRepoName:    repoSplit[1],
 					GitRepoOwner:   repoSplit[0],
 					GitRepoOwner:   repoSplit[0],
@@ -923,7 +924,7 @@ func (app *App) HandleReleaseDeployWebhook(w http.ResponseWriter, r *http.Reques
 	if err != nil {
 	if err != nil {
 		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
 		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
 			Code:   ErrReleaseDeploy,
 			Code:   ErrReleaseDeploy,
-			Errors: []string{"error upgrading release " + err.Error()},
+			Errors: []string{err.Error()},
 		}, w)
 		}, w)
 
 
 		return
 		return
@@ -1056,6 +1057,7 @@ func (app *App) HandleRollbackRelease(w http.ResponseWriter, r *http.Request) {
 				}
 				}
 
 
 				gaRunner := &actions.GithubActions{
 				gaRunner := &actions.GithubActions{
+					ServerURL:      app.ServerConf.ServerURL,
 					GitIntegration: gr,
 					GitIntegration: gr,
 					GitRepoName:    repoSplit[1],
 					GitRepoName:    repoSplit[1],
 					GitRepoOwner:   repoSplit[0],
 					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


+ 28 - 13
server/router/router.go

@@ -11,8 +11,8 @@ import (
 	"github.com/go-chi/chi/middleware"
 	"github.com/go-chi/chi/middleware"
 	"github.com/porter-dev/porter/internal/auth/token"
 	"github.com/porter-dev/porter/internal/auth/token"
 	"github.com/porter-dev/porter/server/api"
 	"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
 // 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(
 			r.Method(
 				"DELETE",
 				"DELETE",
@@ -84,12 +93,6 @@ func New(a *api.App) *chi.Mux {
 				requestlog.NewHandler(a.HandleCLILoginExchangeToken, l),
 				requestlog.NewHandler(a.HandleCLILoginExchangeToken, l),
 			)
 			)
 
 
-			r.Method(
-				"POST",
-				"/login",
-				requestlog.NewHandler(a.HandleLoginUser, l),
-			)
-
 			r.Method(
 			r.Method(
 				"GET",
 				"GET",
 				"/auth/check",
 				"/auth/check",
@@ -212,6 +215,18 @@ func New(a *api.App) *chi.Mux {
 				requestlog.NewHandler(a.HandleGithubOAuthCallback, l),
 				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(
 			r.Method(
 				"GET",
 				"GET",
 				"/oauth/projects/{project_id}/digitalocean",
 				"/oauth/projects/{project_id}/digitalocean",