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

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

jnfrati 5 лет назад
Родитель
Сommit
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
 - Visualize, deploy, and configure Helm charts via the GUI
-- User-generated [form overlays](https://docs.getporter.dev/docs/porter-templates) for managing `values.yaml`
+- User-generated [form overlays](https://github.com/porter-dev/porter-charts/blob/master/docs/form-yaml-reference.md) for managing `values.yaml`
 - In-depth view of releases, including revision histories and component graphs
 - Rollback/update of existing releases, including editing of raw `values.yaml`
 

+ 6 - 2
cmd/app/main.go

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

+ 1 - 0
dashboard/decs.d.ts

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

+ 11 - 10
dashboard/package-lock.json

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

+ 1 - 2
dashboard/package.json

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

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

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

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

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

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

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

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

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

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

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

+ 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) => {
     let { currentCluster, currentProject } = this.context;
-    let protocol = process.env.NODE_ENV == "production" ? "wss" : "ws";
+    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
+
     let ws = new WebSocket(
-      `${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`
+      `${protocol}://${window.location.host}/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`
     );
     ws.onopen = () => {
       console.log("connected to websocket");

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

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

+ 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)
-  getChartData = (chart: ChartType) => {
+  getChartData = (chart: ChartType, revision: number) => {
     let { currentProject } = this.context;
     let { currentCluster, currentChart } = this.props;
 
@@ -77,12 +77,15 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
         },
         {
           name: chart.name,
-          revision: chart.version,
+          revision: revision,
           id: currentProject.id,
         }
       )
       .then((res) => {
         let image = res.data?.config?.image?.repository;
+        let tag = res.data?.config?.image?.tag.toString();
+        let newestImage = tag ? image + ":" + tag : image;
+
         if (
           (image === "porterdev/hello-porter-job" ||
             image === "public.ecr.aws/o1j4x7p4/hello-porter-job") &&
@@ -93,7 +96,7 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
               currentChart: res.data,
               loading: false,
               imageIsPlaceholder: true,
-              newestImage: image,
+              newestImage: newestImage,
             },
             () => {
               this.updateTabs();
@@ -101,7 +104,11 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
           );
         } else {
           this.setState(
-            { currentChart: res.data, loading: false, newestImage: image },
+            {
+              currentChart: res.data,
+              loading: false,
+              newestImage: newestImage,
+            },
             () => {
               this.updateTabs();
             }
@@ -111,7 +118,8 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
       .catch(console.log);
   };
 
-  refreshChart = () => this.getChartData(this.state.currentChart);
+  refreshChart = (revision: number) =>
+    this.getChartData(this.state.currentChart, revision);
 
   mergeNewJob = (newJob: any) => {
     let jobs = this.state.jobs;
@@ -137,9 +145,9 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
     let chartVersion = `${chart.chart.metadata.name}-${chart.chart.metadata.version}`;
 
     let { currentCluster, currentProject } = this.context;
-    let protocol = process.env.NODE_ENV == "production" ? "wss" : "ws";
+    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
     let ws = new WebSocket(
-      `${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/k8s/job/status?cluster_id=${currentCluster.id}`
+      `${protocol}://${window.location.host}/api/projects/${currentProject.id}/k8s/job/status?cluster_id=${currentCluster.id}`
     );
     ws.onopen = () => {
       console.log("connected to websocket");
@@ -181,13 +189,13 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
   };
 
   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 protocol = process.env.NODE_ENV == "production" ? "wss" : "ws";
+    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
     let ws = new WebSocket(
-      `${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/k8s/cronjob/status?cluster_id=${currentCluster.id}`
+      `${protocol}://${window.location.host}/api/projects/${currentProject.id}/k8s/cronjob/status?cluster_id=${currentCluster.id}`
     );
     ws.onopen = () => {
       console.log("connected to websocket");
@@ -204,20 +212,20 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
         this.state.imageIsPlaceholder
       ) {
         // 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 (
           relNameAnn &&
           relNamespaceAnn &&
           releaseName == relNameAnn &&
           releaseNamespace == relNamespaceAnn
         ) {
-          console.log("belonged to chart");
           let newestImage =
             event.Object?.spec?.jobTemplate?.spec?.template?.spec?.containers[0]
               ?.image;
-          console.log("newest image", newestImage)
           if (
             newestImage &&
             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:latest"
           ) {
-            console.log("setting image placeholder to false");
             this.setState({ newestImage, imageIsPlaceholder: false });
           }
         }
@@ -255,15 +262,15 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
       let imageUrl = this.state.newestImage;
       let tag = null;
 
-      if (imageUrl.includes(":")) {
-        let splits = imageUrl.split(":");
-        imageUrl = splits[0];
-        tag = splits[1];
-      } else if (!tag) {
-        tag = "latest";
-      }
-
       if (imageUrl) {
+        if (imageUrl.includes(":")) {
+          let splits = imageUrl.split(":");
+          imageUrl = splits[0];
+          tag = splits[1].toString();
+        } else if (!tag) {
+          tag = "latest";
+        }
+
         _.set(values, "image.repository", imageUrl);
         _.set(values, "image.tag", tag);
       }
@@ -281,26 +288,28 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
       }
 
       let imageUrl = this.state.newestImage;
-      let tag = null;
-
-      if (imageUrl.includes(":")) {
-        let splits = imageUrl.split(":");
-        imageUrl = splits[0];
-        tag = splits[1];
-      } else if (!tag) {
-        tag = "latest";
-      }
+      let tag = null as string;
 
       if (imageUrl) {
+        if (imageUrl.includes(":")) {
+          let splits = imageUrl.split(":");
+          imageUrl = splits[0];
+          tag = splits[1].toString();
+        } else if (!tag) {
+          tag = "latest";
+        }
+
         _.set(values, "image.repository", imageUrl);
-        _.set(values, "image.tag", tag);
+        _.set(values, "image.tag", `${tag}`);
       }
 
       // Weave in preexisting values and convert to yaml
-      conf = yaml.dump({
-        ...(this.state.currentChart.config as Object),
-        ...values,
-      });
+      conf = yaml.dump(
+        {
+          ...(this.state.currentChart.config as Object),
+          ...values,
+        }, { forceQuotes: true }
+      );
     }
 
     api
@@ -319,12 +328,21 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
       )
       .then((res) => {
         this.setState({ saveValuesStatus: "successful" });
-        this.refreshChart();
+        this.refreshChart(0);
       })
       .catch((err) => {
-        console.log(err);
-        this.setState({ saveValuesStatus: "error" });
-        setCurrentError(JSON.stringify(err));
+        let parsedErr =
+          err?.response?.data?.errors && err.response.data.errors[0];
+
+        if (parsedErr) {
+          err = parsedErr;
+        }
+
+        this.setState({
+          saveValuesStatus: parsedErr,
+        });
+
+        setCurrentError(parsedErr);
       });
   };
 
@@ -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:latest"
     ) {
-      console.log("set to false on sorting")
       this.setState({ jobs, newestImage, imageIsPlaceholder: false });
     } else {
       this.setState({ jobs });
@@ -404,7 +421,7 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
         return (
           <SettingsSection
             currentChart={this.state.currentChart}
-            refreshChart={this.refreshChart}
+            refreshChart={() => this.refreshChart(0)}
             setShowDeleteOverlay={(x: boolean) =>
               this.setState({ showDeleteOverlay: x })
             }
@@ -473,7 +490,7 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
       chart: currentChart.name,
     });
 
-    this.getChartData(currentChart);
+    this.getChartData(currentChart, currentChart.version);
     this.getJobs(currentChart);
     this.setupJobWebsocket(currentChart);
     this.setupCronJobWebsocket(currentChart);
@@ -537,7 +554,7 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
               </Title>
               <InfoWrapper>
                 <LastDeployed>
-                  Run {this.state.jobs.length} times <Dot>•</Dot>Last run
+                  Run {this.state.jobs.length} times <Dot>•</Dot>Last template update at
                   {" " + this.readableDate(chart.info.last_deployed)}
                 </LastDeployed>
               </InfoWrapper>

+ 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) => {
     this.setState({ saveValuesStatus: "loading" });
-    let { currentCluster, currentProject } = this.context;
+    let { currentCluster, currentProject, setCurrentError } = this.context;
 
     // If tag is explicitly declared, parse tag
     let imgSplits = img.split(":");
@@ -131,8 +131,18 @@ export default class SettingsSection extends Component<PropsType, StateType> {
         this.props.refreshChart();
       })
       .catch((err) => {
-        console.log(err);
-        this.setState({ saveValuesStatus: "error" });
+        let parsedErr =
+          err?.response?.data?.errors && err.response.data.errors[0];
+
+        if (parsedErr) {
+          err = parsedErr;
+        }
+
+        this.setState({
+          saveValuesStatus: parsedErr,
+        });
+
+        setCurrentError(parsedErr);
       });
   };
 

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

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

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

@@ -14,6 +14,7 @@ type StateType = {
   logs: Anser.AnserJsonEntry[][];
   ws: any;
   scroll: boolean;
+  currentTab: string;
 };
 
 export default class Logs extends Component<PropsType, StateType> {
@@ -21,6 +22,7 @@ export default class Logs extends Component<PropsType, StateType> {
     logs: [] as Anser.AnserJsonEntry[][],
     ws: null as any,
     scroll: true,
+    currentTab: "Application",
   };
 
   ws = null as any;
@@ -92,48 +94,13 @@ export default class Logs extends Component<PropsType, StateType> {
     });
   };
 
-  getPodStatus = (status: any) => {
-    if (
-      status?.phase === "Pending" &&
-      status?.containerStatuses !== undefined
-    ) {
-      return status.containerStatuses[0].state.waiting.reason;
-    } else if (status?.phase === "Pending") {
-      return "pending";
-    }
-
-    if (status?.phase === "Succeeded") {
-      return "succeeded";
-    }
-
-    if (status?.phase === "Failed") {
-      return "failed";
-    }
-
-    if (status?.phase === "Running") {
-      let collatedStatus = "running";
-
-      status?.containerStatuses?.forEach((s: any) => {
-        if (s.state?.waiting) {
-          collatedStatus =
-            s.state?.waiting.reason === "CrashLoopBackOff"
-              ? "failed"
-              : "waiting";
-        } else if (s.state?.terminated) {
-          collatedStatus = "failed";
-        }
-      });
-      return collatedStatus;
-    }
-  };
-
   setupWebsocket = () => {
     let { currentCluster, currentProject } = this.context;
     let { selectedPod } = this.props;
     if (!selectedPod?.metadata?.name) return;
-    let protocol = process.env.NODE_ENV == "production" ? "wss" : "ws";
+    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
     this.ws = new WebSocket(
-      `${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/k8s/${selectedPod?.metadata?.namespace}/pod/${selectedPod?.metadata?.name}/logs?cluster_id=${currentCluster.id}&service_account_id=${currentCluster.service_account_id}`
+      `${protocol}://${window.location.host}/api/projects/${currentProject.id}/k8s/${selectedPod?.metadata?.namespace}/pod/${selectedPod?.metadata?.name}/logs?cluster_id=${currentCluster.id}&service_account_id=${currentCluster.service_account_id}`
     );
 
     this.ws.onopen = () => {};
@@ -157,24 +124,33 @@ export default class Logs extends Component<PropsType, StateType> {
   };
 
   refreshLogs = () => {
-    if (this.ws) {
+    let { selectedPod } = this.props;
+    if (this.ws && this.state.currentTab == "Application") {
       this.ws.close();
       this.ws = null;
       this.setState({ logs: [] });
       this.setupWebsocket();
     }
+    this.retrieveEvents(selectedPod);
   };
 
-  componentDidMount() {
-    let { selectedPod } = this.props;
-    let status = this.getPodStatus(selectedPod?.status);
-    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
       .getPodEvents(
         "<token>",
@@ -205,6 +181,18 @@ export default class Logs extends Component<PropsType, StateType> {
       .catch((err) => {
         console.log(err);
       });
+  };
+
+  componentDidMount() {
+    let { selectedPod } = this.props;
+
+    if (this.state.currentTab == "Application") {
+      this.setupWebsocket();
+      this.scrollToBottom(false);
+      return;
+    }
+
+    this.retrieveEvents(selectedPod);
   }
 
   componentWillUnmount() {
@@ -218,6 +206,24 @@ export default class Logs extends Component<PropsType, StateType> {
       return (
         <LogStreamAlt>
           <Wrapper ref={this.parentRef}>{this.renderLogs()}</Wrapper>
+          <LogTabs>
+            <Tab
+              onClick={() => {
+                this.setState({ currentTab: "Application" });
+              }}
+              clicked={this.state.currentTab == "Application"}
+            >
+              Application
+            </Tab>
+            <Tab
+              onClick={() => {
+                this.setState({ currentTab: "System" });
+              }}
+              clicked={this.state.currentTab == "System"}
+            >
+              System
+            </Tab>
+          </LogTabs>
         </LogStreamAlt>
       );
     }
@@ -225,6 +231,24 @@ export default class Logs extends Component<PropsType, StateType> {
     return (
       <LogStream>
         <Wrapper ref={this.parentRef}>{this.renderLogs()}</Wrapper>
+        <LogTabs>
+          <Tab
+            onClick={() => {
+              this.setState({ currentTab: "Application" });
+            }}
+            clicked={this.state.currentTab == "Application"}
+          >
+            Application
+          </Tab>
+          <Tab
+            onClick={() => {
+              this.setState({ currentTab: "System" });
+            }}
+            clicked={this.state.currentTab == "System"}
+          >
+            System
+          </Tab>
+        </LogTabs>
         <Options>
           <Scroll
             onClick={() => {
@@ -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`
   display: flex;
   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`
   width: 100%;
   height: 25px;

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

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

+ 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,
             <Link
               target="_blank"
-              href="https://docs.getporter.dev/docs/porter-templates"
+              href="https://docs.getporter.dev/docs/add-ons"
             >
               refer to our docs
             </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) => {
-    let { currentProject, currentCluster } = this.context;
+    let { currentProject, currentCluster, setCurrentError } = this.context;
     let {
       actionConfig,
       branch,
@@ -123,6 +123,8 @@ class LaunchFlow extends Component<PropsType, StateType> {
         this.setState({
           saveValuesStatus: `Could not create GitHub Action: ${err}`,
         });
+
+        setCurrentError(err);
       });
   };
 
@@ -179,7 +181,7 @@ class LaunchFlow extends Component<PropsType, StateType> {
           err = parsedErr;
         }
         this.setState({
-          saveValuesStatus: `Could not deploy template: ${err}`,
+          saveValuesStatus: parsedErr,
         });
         setCurrentError(err.response.data.errors[0]);
         window.analytics.track("Failed to Deploy Add-on", {
@@ -192,7 +194,7 @@ class LaunchFlow extends Component<PropsType, StateType> {
   };
 
   onSubmit = async (rawValues: any) => {
-    let { currentCluster, currentProject } = this.context;
+    let { currentCluster, currentProject, setCurrentError } = this.context;
     let {
       selectedNamespace,
       templateName,
@@ -278,6 +280,8 @@ class LaunchFlow extends Component<PropsType, StateType> {
               this.setState({
                 saveValuesStatus: `Could not create subdomain: ${err}`,
               });
+
+              setCurrentError(err);
             });
         });
 
@@ -332,6 +336,7 @@ class LaunchFlow extends Component<PropsType, StateType> {
         this.setState({
           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,
             <Link
               target="_blank"
-              href="https://docs.getporter.dev/docs/porter-templates"
+              href="https://github.com/porter-dev/porter-charts/blob/master/docs/form-yaml-reference.md"
             >
               refer to our docs
             </Link>

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

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

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

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

+ 19 - 6
internal/config/config.go

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

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

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

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

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

+ 10 - 0
internal/kubernetes/config.go

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

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

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

+ 1 - 0
internal/models/user.go

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

+ 13 - 0
internal/oauth/config.go

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

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

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

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

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

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

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

+ 1 - 0
internal/repository/user.go

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

+ 71 - 19
server/api/api.go

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

+ 1 - 15
server/api/capability_handler.go

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

+ 1 - 0
server/api/deploy_handler.go

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

+ 1 - 9
server/api/dns_record_handler.go

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

+ 1 - 0
server/api/git_action_handler.go

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

+ 213 - 0
server/api/oauth_google_handler.go

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

+ 12 - 130
server/api/provision_handler.go

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

+ 4 - 2
server/api/release_handler.go

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

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


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


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


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


+ 28 - 13
server/router/router.go

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