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

Merge branch 'master' into beta.3.cicd

Jo Chuang 5 лет назад
Родитель
Сommit
28b2f23baf
37 измененных файлов с 1112 добавлено и 116 удалено
  1. 23 0
      .github/ISSUE_TEMPLATE/bug.md
  2. 17 0
      .github/ISSUE_TEMPLATE/change.md
  3. 15 0
      .github/ISSUE_TEMPLATE/feature.md
  4. 30 0
      .github/PULL_REQUEST_TEMPLATE.md
  5. 2 2
      .github/workflows/staging.yaml
  6. 9 8
      README.md
  7. 87 30
      cli/cmd/auth.go
  8. 309 0
      cli/cmd/login/server.go
  9. 1 0
      cmd/app/main.go
  10. 29 0
      cmd/migrate/keyrotate/rotate.go
  11. 1 0
      cmd/migrate/main.go
  12. 101 0
      dashboard/package-lock.json
  13. 6 0
      dashboard/package.json
  14. 1 0
      dashboard/src/App.tsx
  15. 4 2
      dashboard/src/main/home/Home.tsx
  16. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  17. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx
  18. 18 30
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/AreaChart.tsx
  19. 153 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricsSection.tsx
  20. 8 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx
  21. 5 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  22. 4 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx
  23. 2 2
      dashboard/src/main/home/sidebar/Sidebar.tsx
  24. 3 3
      dashboard/src/main/home/templates/expanded-template/LaunchTemplate.tsx
  25. 6 7
      internal/auth/token/token.go
  26. 1 1
      internal/auth/token/token_test.go
  27. 11 11
      internal/integrations/ci/actions/actions.go
  28. 21 0
      internal/models/auth_code.go
  29. 11 0
      internal/repository/auth_code.go
  30. 37 0
      internal/repository/gorm/auth_code.go
  31. 1 0
      internal/repository/gorm/repository.go
  32. 54 0
      internal/repository/memory/auth_code.go
  33. 1 0
      internal/repository/memory/repository.go
  34. 1 0
      internal/repository/repository.go
  35. 102 0
      server/api/user_handler.go
  36. 22 12
      server/router/middleware/auth.go
  37. 14 0
      server/router/router.go

+ 23 - 0
.github/ISSUE_TEMPLATE/bug.md

@@ -0,0 +1,23 @@
+---
+name: Bug Report
+about: 🐛 Found a bug? Let us know! 
+
+---
+
+# Description
+
+<!-- Please provide a high-level description of what you were trying to accomplish and what went wrong. --> 
+
+# Location
+
+- [ ] Browser 
+- [ ] CLI 
+- [ ] API
+
+# Steps to reproduce
+
+1. 
+2. 
+3. 
+
+# Additional Details

+ 17 - 0
.github/ISSUE_TEMPLATE/change.md

@@ -0,0 +1,17 @@
+---
+name: Change
+about: 🛠️ Update functionality that already exists. 
+
+---
+
+# Location
+
+- [ ] Browser 
+- [ ] CLI 
+- [ ] API
+
+# Motivation
+
+# Requirements
+
+# Additional Details

+ 15 - 0
.github/ISSUE_TEMPLATE/feature.md

@@ -0,0 +1,15 @@
+---
+name: Feature
+about: ✨ Add new functionality to the project.
+
+---
+
+# Location
+
+- [ ] Browser 
+- [ ] CLI 
+- [ ] API
+
+# Requirements
+
+# Additional Details

+ 30 - 0
.github/PULL_REQUEST_TEMPLATE.md

@@ -0,0 +1,30 @@
+## Pull request type
+
+<!-- Please try to limit your pull request to one type, submit multiple pull requests if needed. --> 
+
+Please check the type of change your PR introduces:
+- [ ] Bugfix
+- [ ] Feature
+- [ ] Other (please describe): 
+
+## Pull request checklist
+
+Please check if your PR fulfills the following requirements:
+- [ ] If it's a backend change, tests for the changes have been added and `go test ./...` runs successfully from the root folder. 
+- [ ] If it's a frontend change, Prettier has been run
+- [ ] Docs have been reviewed and added / updated if needed
+
+## What is the current behavior?
+<!-- Please describe the current behavior that you are modifying, or link to a relevant issue. 
+
+Issue Number: N/A
+
+-->
+
+
+## What is the new behavior?
+<!-- Please describe the behavior or changes that are being added by this PR. -->
+
+<!-- Any other information that is important to this PR such as screenshots of how the component looks before and after the change. -->
+
+## Technical Spec/Implementation Notes

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

@@ -31,7 +31,7 @@ jobs:
         EOL
         EOL
     - name: Build
     - name: Build
       run: |
       run: |
-        DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/porter-prov:latest -f ./docker/Dockerfile
+        DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/porter:staging -f ./docker/Dockerfile
     - name: Push
     - name: Push
       run: |
       run: |
-        docker push gcr.io/porter-dev-273614/porter-prov:latest
+        docker push gcr.io/porter-dev-273614/porter:staging

+ 9 - 8
README.md

@@ -1,6 +1,6 @@
 # Porter 
 # Porter 
 [![MIT License](https://img.shields.io/apm/l/atomic-design-ui.svg?)](https://github.com/tterb/atomic-design-ui/blob/master/LICENSEs) [![Go Report Card](https://goreportcard.com/badge/gojp/goreportcard)](https://goreportcard.com/report/github.com/porter-dev/porter) [![Discord](https://img.shields.io/discord/542888846271184896?color=7389D8&label=community&logo=discord&logoColor=ffffff)](https://discord.gg/MhYNuWwqum)
 [![MIT License](https://img.shields.io/apm/l/atomic-design-ui.svg?)](https://github.com/tterb/atomic-design-ui/blob/master/LICENSEs) [![Go Report Card](https://goreportcard.com/badge/gojp/goreportcard)](https://goreportcard.com/report/github.com/porter-dev/porter) [![Discord](https://img.shields.io/discord/542888846271184896?color=7389D8&label=community&logo=discord&logoColor=ffffff)](https://discord.gg/MhYNuWwqum)
-
+[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/cloudposse.svg?style=social&label=Follow)](https://twitter.com/getporterdev)
 
 
 **Porter is a Kubernetes-powered PaaS that runs in your own cloud provider.** Porter brings the Heroku experience to Kubernetes without compromising its flexibility. Get started on Porter without the overhead of DevOps and fully customize your infra later when you need to.
 **Porter is a Kubernetes-powered PaaS that runs in your own cloud provider.** Porter brings the Heroku experience to Kubernetes without compromising its flexibility. Get started on Porter without the overhead of DevOps and fully customize your infra later when you need to.
 
 
@@ -10,7 +10,7 @@
 
 
 For help, questions, or if you just want a place to hang out, [join our Discord community.](https://discord.gg/MhYNuWwqum)
 For help, questions, or if you just want a place to hang out, [join our Discord community.](https://discord.gg/MhYNuWwqum)
 
 
-To keep updated on our progress, please watch the repo for new releases (**Watch > Custom > Releases**) and [follow us on Twitter!](https://twitter.com/getporterdev)
+To keep updated on our progress, please watch the repo for new releases (**Watch > Custom > Releases**) and [follow us on Twitter](https://twitter.com/getporterdev)!
 
 
 ## Why Porter?
 ## Why Porter?
 ### A PaaS that grows with your applications
 ### A PaaS that grows with your applications
@@ -32,7 +32,7 @@ Porter brings the simplicity of a traditional PaaS to your own cloud provider wh
 - Marketplace for one click add-ons (e.g. MongoDB, Redis, PostgreSQL)
 - Marketplace for one click add-ons (e.g. MongoDB, Redis, PostgreSQL)
 - Application rollback to previously deployed versions
 - Application rollback to previously deployed versions
 - Deploy webhooks that can be triggered from CI/CD pipelines
 - Deploy webhooks that can be triggered from CI/CD pipelines
-- Native CI/CD with buildpacks for non-Dockerized apps (Coming Soon)
+- Native CI/CD with buildpacks for non-Dockerized apps (🚧 Coming Soon)
 
 
 ### DevOps Mode
 ### DevOps Mode
 For those who are familiar with Kubernetes and Helm:
 For those who are familiar with Kubernetes and Helm:
@@ -47,7 +47,7 @@ For those who are familiar with Kubernetes and Helm:
 
 
 ## Docs
 ## Docs
 
 
-Below are instructions for a quickstart. For full documentation, visit our [official Docs page.](https://docs.getporter.dev)
+Below are instructions for a quickstart. For full documentation, please visit our [official Docs.](https://docs.getporter.dev)
 
 
 ## CLI Installation
 ## CLI Installation
 ### Mac 
 ### Mac 
@@ -70,19 +70,20 @@ chmod +x ./porter
 sudo mv ./porter /usr/local/bin/porter
 sudo mv ./porter /usr/local/bin/porter
 ```
 ```
 
 
-For Linux and Windows installation, see our [Docs](https://docs.getporter.dev/docs/cli-documentation#linux). 
+For Linux and Windows installation, see our [Docs](https://docs.getporter.dev/docs/cli-documentation#linux).
 
 
 ## Getting Started
 ## Getting Started
 1. Sign up and log into [Porter Dashboard](https://dashboard.getporter.dev).
 1. Sign up and log into [Porter Dashboard](https://dashboard.getporter.dev).
 
 
-2. Create a Project and select a cloud provider you want to provision a Kubernetes cluster in (AWS, GCP, DO). It is also possible to [link up your own Kubernetes cluster.](https://docs.getporter.dev/docs/cli-documentation#linking-your-own-private-image-registry)
+2. Create a Project and select a cloud provider you want to provision a Kubernetes cluster in (AWS, GCP, DO). It is also possible to [link up your own Kubernetes cluster.](https://docs.getporter.dev/docs/cli-documentation#connecting-to-an-existing-cluster)
 
 
 3. [Put in your credentials](https://docs.getporter.dev/docs/getting-started-with-porter-on-aws), then Porter will automatically provision a cluster and an image registry in your own cloud account.
 3. [Put in your credentials](https://docs.getporter.dev/docs/getting-started-with-porter-on-aws), then Porter will automatically provision a cluster and an image registry in your own cloud account.
 
 
-4. [Build and push your Docker image](https://docs.getporter.dev/docs/cli-documentation#porter-docker-configure), or connect your git repository if your application is not dockerized.
+4. [Build and push your Docker image](https://docs.getporter.dev/docs/cli-documentation#porter-docker-configure), or connect your git repository. We are currently working on supporting the latter option for non-Dockerized applications.
 
 
 5. From the Templates tab on the Dashboard, select the Docker template. Click on the image you have just pushed, configure the port, then hit deploy.
 5. From the Templates tab on the Dashboard, select the Docker template. Click on the image you have just pushed, configure the port, then hit deploy.
 
 
 ## Want to Help?
 ## Want to Help?
-We welcome all contributions. Submit an issue or a pull request to help us improve Porter!
+We welcome all contributions. Submit an issue or a pull request to help us improve Porter! If you're interested in contributing, please [join our Discord community.](https://discord.gg/MhYNuWwqum) 
+
 ![porter](https://user-images.githubusercontent.com/65516095/103712859-def9ee00-4f88-11eb-804c-4b775d697ec4.jpeg)
 ![porter](https://user-images.githubusercontent.com/65516095/103712859-def9ee00-4f88-11eb-804c-4b775d697ec4.jpeg)

+ 87 - 30
cli/cmd/auth.go

@@ -8,6 +8,7 @@ import (
 	"github.com/fatih/color"
 	"github.com/fatih/color"
 
 
 	"github.com/porter-dev/porter/cli/cmd/api"
 	"github.com/porter-dev/porter/cli/cmd/api"
+	loginBrowser "github.com/porter-dev/porter/cli/cmd/login"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/spf13/cobra"
 	"github.com/spf13/cobra"
 )
 )
@@ -56,6 +57,7 @@ var logoutCmd = &cobra.Command{
 }
 }
 
 
 var token string = ""
 var token string = ""
+var manual bool = false
 
 
 func init() {
 func init() {
 	rootCmd.AddCommand(authCmd)
 	rootCmd.AddCommand(authCmd)
@@ -77,38 +79,102 @@ func init() {
 		"",
 		"",
 		"bearer token for authentication",
 		"bearer token for authentication",
 	)
 	)
+
+	loginCmd.PersistentFlags().BoolVar(
+		&manual,
+		"manual",
+		false,
+		"whether to prompt for manual authentication (username/pw)",
+	)
 }
 }
 
 
 func login() error {
 func login() error {
-	var client *api.Client
+	client := api.NewClientWithToken(getHost()+"/api", getToken())
+
+	user, _ := client.AuthCheck(context.Background())
+
+	if user != nil {
+		color.Yellow("You are already logged in. If you'd like to log out, run \"porter auth logout\".")
+		return nil
+	}
+
+	// check for the --manual flag
+	if manual {
+		return loginManual()
+	}
+
+	// check for a token
+	var err error
+
+	if token == "" {
+		token, err = loginBrowser.Login(getHost())
+
+		if err != nil {
+			return err
+		}
 
 
-	if token != "" {
 		// set the token in config
 		// set the token in config
-		err := setToken(token)
+		err = setToken(token)
 
 
 		if err != nil {
 		if err != nil {
 			return err
 			return err
 		}
 		}
 
 
-		client = api.NewClientWithToken(getHost()+"/api", token)
+		client := api.NewClientWithToken(getHost()+"/api", token)
 
 
-		user, _ := client.AuthCheck(context.Background())
+		user, err := client.AuthCheck(context.Background())
 
 
 		if user == nil {
 		if user == nil {
 			color.Red("Invalid token.")
 			color.Red("Invalid token.")
-			return nil
+			return err
+		}
+
+		color.New(color.FgGreen).Println("Successfully logged in!")
+
+		// get a list of projects, and set the current project
+		projects, err := client.ListUserProjects(context.Background(), user.ID)
+
+		if err != nil {
+			return err
+		}
+
+		if len(projects) > 0 {
+			setProject(projects[0].ID)
 		}
 		}
 	} else {
 	} else {
-		client = api.NewClient(getHost()+"/api", "cookie.json")
-	}
+		// set the token in config
+		err = setToken(token)
 
 
-	user, _ := client.AuthCheck(context.Background())
+		if err != nil {
+			return err
+		}
 
 
-	if user != nil {
-		color.Yellow("You are already logged in. If you'd like to log out, run \"porter auth logout\".")
-		return nil
+		client := api.NewClientWithToken(getHost()+"/api", token)
+
+		user, err := client.AuthCheck(context.Background())
+
+		if user == nil {
+			color.Red("Invalid token.")
+			return err
+		}
+
+		color.New(color.FgGreen).Println("Successfully logged in!")
+
+		projID, err := api.GetProjectIDFromToken(token)
+
+		if err != nil {
+			return err
+		}
+
+		setProject(projID)
 	}
 	}
 
 
+	return nil
+}
+
+func loginManual() error {
+	client := api.NewClient(getHost()+"/api", "cookie.json")
+
 	var username, pw string
 	var username, pw string
 
 
 	fmt.Println("Please log in with an email and password:")
 	fmt.Println("Please log in with an email and password:")
@@ -136,26 +202,15 @@ func login() error {
 
 
 	color.New(color.FgGreen).Println("Successfully logged in!")
 	color.New(color.FgGreen).Println("Successfully logged in!")
 
 
-	// if the login was token-based, decode the claims to get the token
-	if token != "" {
-		projID, err := api.GetProjectIDFromToken(token)
-
-		if err != nil {
-			return err
-		}
+	// get a list of projects, and set the current project
+	projects, err := client.ListUserProjects(context.Background(), _user.ID)
 
 
-		setProject(projID)
-	} else {
-		// get a list of projects, and set the current project
-		projects, err := client.ListUserProjects(context.Background(), _user.ID)
-
-		if err != nil {
-			return err
-		}
+	if err != nil {
+		return err
+	}
 
 
-		if len(projects) > 0 {
-			setProject(projects[0].ID)
-		}
+	if len(projects) > 0 {
+		setProject(projects[0].ID)
 	}
 	}
 
 
 	return nil
 	return nil
@@ -199,6 +254,8 @@ func logout(user *api.AuthCheckResponse, client *api.Client, args []string) erro
 		return err
 		return err
 	}
 	}
 
 
+	setToken("")
+
 	color.Green("Successfully logged out")
 	color.Green("Successfully logged out")
 
 
 	return nil
 	return nil

+ 309 - 0
cli/cmd/login/server.go

@@ -0,0 +1,309 @@
+package login
+
+import (
+	"encoding/json"
+	"fmt"
+	"net"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"github.com/porter-dev/porter/cli/cmd/utils"
+)
+
+func redirect(
+	codechan chan string,
+) func(http.ResponseWriter, *http.Request) {
+	return func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "text/html; charset=utf-8")
+		fmt.Fprint(w, successScreen)
+
+		queryParams, _ := url.ParseQuery(r.URL.RawQuery)
+
+		codechan <- queryParams["code"][0]
+	}
+}
+
+func Login(
+	host string,
+) (string, error) {
+	listener, err := net.Listen("tcp", ":0")
+
+	if err != nil {
+		panic(err)
+	}
+
+	port := listener.Addr().(*net.TCPAddr).Port
+
+	errorchan := make(chan error)
+	codechan := make(chan string)
+
+	go func() {
+		http.HandleFunc("/", redirect(
+			codechan,
+		))
+
+		err := http.Serve(listener, nil)
+		errorchan <- err
+	}()
+
+	// open browser for host login
+	redirectHost := fmt.Sprintf("http://localhost:%d", port)
+	loginURL := fmt.Sprintf("%s/api/cli/login?redirect=%s", host, url.QueryEscape(redirectHost))
+
+	err = utils.OpenBrowser(loginURL)
+
+	if err != nil {
+		return "", fmt.Errorf("Could not open browser: %v", err)
+	}
+
+	for {
+		select {
+		case err = <-errorchan:
+			return "", err
+		case code := <-codechan:
+			return ExchangeToken(host, code)
+		}
+	}
+}
+
+type ExchangeResponse struct {
+	Token string `json:"token"`
+}
+
+func ExchangeToken(host, code string) (string, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/api/cli/login/exchange", host),
+		strings.NewReader(fmt.Sprintf(`{"authorization_code": "%s"}`, code)),
+	)
+
+	if err != nil {
+		return "", err
+	}
+
+	// create a request with the authorization code
+	req.Header.Set("Content-Type", "application/json; charset=utf-8")
+	req.Header.Set("Accept", "application/json; charset=utf-8")
+
+	client := &http.Client{
+		Timeout: time.Minute,
+	}
+
+	res, err := client.Do(req)
+
+	if err != nil {
+		return "", err
+	}
+
+	defer res.Body.Close()
+
+	resp := &ExchangeResponse{}
+
+	if err = json.NewDecoder(res.Body).Decode(resp); err != nil {
+		return "", err
+	}
+
+	return resp.Token, nil
+}
+
+const successScreen = `
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset='UTF-8'>
+    <title>Porter | Login</title>
+    <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+    <link href="https://fonts.googleapis.com/css?family=Assistant:400,700|Noto+Sans:400,600,700|Work+Sans:400,500,600|Source+Sans+Pro:400,600,700|Hind+Siliguri:500|Cabin:400,600" rel="stylesheet">
+    <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+    <link href="//cdnjs.cloudflare.com/ajax/libs/KaTeX/0.9.0/katex.min.css" rel="stylesheet" />
+    <link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@600&display=swap" rel="stylesheet">
+    <style>
+      #logo {
+        width: 80px;
+        margin-top: -30px;
+        margin-bottom: 40px;
+      }
+
+      #success {
+        font-family: 'Open Sans', sans-serif;
+        font-size: 18px;
+        color: #CBCBD8;
+        margin-bottom: 17px;
+      }
+
+      #subtitle {
+        font-family: 'Open Sans', sans-serif;
+        font-size: 14px;
+        color: #CBCBD8;
+      }
+
+      body{
+        margin: 0;
+        padding: 0;
+        width: 100%;
+        height: 100vh;
+        background: #f1f3f5;
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        justify-content: center;
+      }
+
+      #text {
+        display: flex;
+        height: 100vh;
+        align-items: center;
+        justify-content: center;
+        text-align: center;
+      }
+
+      h2{
+        color: #fff;
+        font-size: 47px;
+        line-height: 40px;
+      }
+
+      #container {
+        left: 0px;
+        top: -100px;
+        height: calc(100vh + 100px);
+        overflow: hidden;
+        position: relative;
+      }
+
+      #animate{
+        margin: 0 auto;
+        width: 20px;
+        overflow: visible;
+        position: relative;
+      }
+
+      #all{
+        overflow: hidden;
+        height: 100vh;
+        width: 100%;
+        position: fixed;
+      }
+
+      #footer{
+        color: #808080;
+        text-decoration: none;
+        position: fixed;
+        width: 752px;
+        bottom: 20px;
+        align-content: center;
+        float: none;
+        margin-left: calc(50% - 376px);
+      }
+
+      a, p{
+        text-decoration: none;
+        color: #808080;
+        letter-spacing: 6px;
+        transition: all 0.5s ease-in-out;
+        width: auto;
+        float: left;
+        margin: 0;
+        margin-right: 9px;
+      }
+
+      a:hover{
+        color: #fff;
+        letter-spacing: 2px;
+        transition: all 0.5s ease-in-out;
+      }
+    </style>
+  </head>
+  <body>
+    <link href="https://fonts.googleapis.com/css?family=Oswald:600,700" rel="stylesheet"> 
+    <div id="all">
+    <div id="container">
+      <div id="animate">
+      </div>
+    </div>
+    </div>
+    <noscript>You need to enable JavaScript to run this app.</noscript>
+    <img id='logo' src='https://i.ibb.co/y64zfm5/porter.png'>
+    <div id='success'>Authentication successful!</div>
+    <div id='subtitle'>You can now close this window.</div>
+    <script>
+/*      setTimeout(function () {
+        window.close();
+      }, 1000)
+      */
+
+      var container = document.getElementById('animate');
+      var emoji = ['🎉'];
+      var circles = [];
+
+      for (var i = 0; i < 15; i++) {
+        addCircle(i * 550, [10 + 0, 300], emoji[Math.floor(Math.random() * emoji.length)]);
+        addCircle(i * 550, [10 + 0, -300], emoji[Math.floor(Math.random() * emoji.length)]);
+        addCircle(i * 550, [10 - 200, -300], emoji[Math.floor(Math.random() * emoji.length)]);
+        addCircle(i * 550, [10 + 200, 300], emoji[Math.floor(Math.random() * emoji.length)]);
+        addCircle(i * 550, [10 - 400, -300], emoji[Math.floor(Math.random() * emoji.length)]);
+        addCircle(i * 550, [10 + 400, 300], emoji[Math.floor(Math.random() * emoji.length)]);
+        addCircle(i * 550, [10 - 600, -300], emoji[Math.floor(Math.random() * emoji.length)]);
+        addCircle(i * 550, [10 + 600, 300], emoji[Math.floor(Math.random() * emoji.length)]);
+      }
+
+
+
+      function addCircle(delay, range, color) {
+        setTimeout(function() {
+          var c = new Circle(range[0] + Math.random() * range[1], 80 + Math.random() * 4, color, {
+            x: -0.15 + Math.random() * 0.3,
+            y: 1 + Math.random() * 1
+          }, range);
+          circles.push(c);
+        }, delay);
+      }
+
+      function Circle(x, y, c, v, range) {
+        var _this = this;
+        this.x = x;
+        this.y = y;
+        this.color = c;
+        this.v = v;
+        this.range = range;
+        this.element = document.createElement('span');
+        /*this.element.style.display = 'block';*/
+        this.element.style.opacity = 0;
+        this.element.style.position = 'absolute';
+        this.element.style.fontSize = '26px';
+        this.element.style.color = 'hsl('+(Math.random()*360|0)+',80%,50%)';
+        this.element.innerHTML = c;
+        container.appendChild(this.element);
+
+        this.update = function() {
+          if (_this.y > 800) {
+            _this.y = 80 + Math.random() * 4;
+            _this.x = _this.range[0] + Math.random() * _this.range[1];
+          }
+          _this.y += _this.v.y;
+          _this.x += _this.v.x;
+          this.element.style.opacity = 1;
+          this.element.style.transform = 'translate3d(' + _this.x + 'px, ' + _this.y + 'px, 0px)';
+          this.element.style.webkitTransform = 'translate3d(' + _this.x + 'px, ' + _this.y + 'px, 0px)';
+          this.element.style.mozTransform = 'translate3d(' + _this.x + 'px, ' + _this.y + 'px, 0px)';
+        };
+      }
+
+      function animate() {
+        for (var i in circles) {
+          circles[i].update();
+        }
+        requestAnimationFrame(animate);
+      }
+      
+      if (Math.random() < 0.001) {
+        animate();
+      }
+
+      
+    </script>
+  </body>
+</html>
+`

+ 1 - 0
cmd/app/main.go

@@ -59,6 +59,7 @@ func main() {
 		&models.Infra{},
 		&models.Infra{},
 		&models.GitActionConfig{},
 		&models.GitActionConfig{},
 		&models.Invite{},
 		&models.Invite{},
+		&models.AuthCode{},
 		&ints.KubeIntegration{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},
 		&ints.OIDCIntegration{},

+ 29 - 0
cmd/migrate/keyrotate/rotate.go

@@ -14,69 +14,98 @@ import (
 const stepSize = 100
 const stepSize = 100
 
 
 func Rotate(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 func Rotate(db *_gorm.DB, oldKey, newKey *[32]byte) error {
+	oldKeyBytes := make([]byte, 32)
+	newKeyBytes := make([]byte, 32)
+
+	copy(oldKeyBytes[:], oldKey[:])
+	copy(newKeyBytes[:], newKey[:])
+
+	fmt.Printf("beginning key rotation from %s to %s\n", string(oldKeyBytes), string(newKeyBytes))
+
 	err := rotateClusterModel(db, oldKey, newKey)
 	err := rotateClusterModel(db, oldKey, newKey)
 
 
 	if err != nil {
 	if err != nil {
+		fmt.Printf("failed on cluster rotation: %v\n", err)
 		return err
 		return err
 	}
 	}
 
 
 	err = rotateClusterCandidateModel(db, oldKey, newKey)
 	err = rotateClusterCandidateModel(db, oldKey, newKey)
 
 
 	if err != nil {
 	if err != nil {
+		fmt.Printf("failed on cc rotation: %v\n", err)
+
 		return err
 		return err
 	}
 	}
 
 
 	err = rotateRegistryModel(db, oldKey, newKey)
 	err = rotateRegistryModel(db, oldKey, newKey)
 
 
 	if err != nil {
 	if err != nil {
+		fmt.Printf("failed on registry rotation: %v\n", err)
+
 		return err
 		return err
 	}
 	}
 
 
 	err = rotateHelmRepoModel(db, oldKey, newKey)
 	err = rotateHelmRepoModel(db, oldKey, newKey)
 
 
 	if err != nil {
 	if err != nil {
+		fmt.Printf("failed on hr rotation: %v\n", err)
+
 		return err
 		return err
 	}
 	}
 
 
 	err = rotateInfraModel(db, oldKey, newKey)
 	err = rotateInfraModel(db, oldKey, newKey)
 
 
 	if err != nil {
 	if err != nil {
+		fmt.Printf("failed on infra rotation: %v\n", err)
+
 		return err
 		return err
 	}
 	}
 
 
 	err = rotateKubeIntegrationModel(db, oldKey, newKey)
 	err = rotateKubeIntegrationModel(db, oldKey, newKey)
 
 
 	if err != nil {
 	if err != nil {
+		fmt.Printf("failed on ki rotation: %v\n", err)
+
 		return err
 		return err
 	}
 	}
 
 
 	err = rotateBasicIntegrationModel(db, oldKey, newKey)
 	err = rotateBasicIntegrationModel(db, oldKey, newKey)
 
 
 	if err != nil {
 	if err != nil {
+		fmt.Printf("failed on basic rotation: %v\n", err)
+
 		return err
 		return err
 	}
 	}
 
 
 	err = rotateOIDCIntegrationModel(db, oldKey, newKey)
 	err = rotateOIDCIntegrationModel(db, oldKey, newKey)
 
 
 	if err != nil {
 	if err != nil {
+		fmt.Printf("failed on oidc rotation: %v\n", err)
+
 		return err
 		return err
 	}
 	}
 
 
 	err = rotateOAuthIntegrationModel(db, oldKey, newKey)
 	err = rotateOAuthIntegrationModel(db, oldKey, newKey)
 
 
 	if err != nil {
 	if err != nil {
+		fmt.Printf("failed on oauth rotation: %v\n", err)
+
 		return err
 		return err
 	}
 	}
 
 
 	err = rotateGCPIntegrationModel(db, oldKey, newKey)
 	err = rotateGCPIntegrationModel(db, oldKey, newKey)
 
 
 	if err != nil {
 	if err != nil {
+		fmt.Printf("failed on gcp rotation: %v\n", err)
+
 		return err
 		return err
 	}
 	}
 
 
 	err = rotateAWSIntegrationModel(db, oldKey, newKey)
 	err = rotateAWSIntegrationModel(db, oldKey, newKey)
 
 
 	if err != nil {
 	if err != nil {
+		fmt.Printf("failed on aws rotation: %v\n", err)
+
 		return err
 		return err
 	}
 	}
 
 

+ 1 - 0
cmd/migrate/main.go

@@ -44,6 +44,7 @@ func main() {
 		&models.Infra{},
 		&models.Infra{},
 		&models.GitActionConfig{},
 		&models.GitActionConfig{},
 		&models.Invite{},
 		&models.Invite{},
+		&models.AuthCode{},
 		&ints.KubeIntegration{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},
 		&ints.OIDCIntegration{},

+ 101 - 0
dashboard/package-lock.json

@@ -10,6 +10,8 @@
       "dependencies": {
       "dependencies": {
         "@fullstory/browser": "^1.4.5",
         "@fullstory/browser": "^1.4.5",
         "@material-ui/core": "^4.11.3",
         "@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/js-yaml": "^3.12.5",
         "@types/lodash": "^4.14.165",
         "@types/lodash": "^4.14.165",
         "@types/markdown-to-jsx": "^6.11.3",
         "@types/markdown-to-jsx": "^6.11.3",
@@ -20,11 +22,15 @@
         "@visx/event": "^1.3.0",
         "@visx/event": "^1.3.0",
         "@visx/gradient": "^1.0.0",
         "@visx/gradient": "^1.0.0",
         "@visx/grid": "^1.4.0",
         "@visx/grid": "^1.4.0",
+        "@visx/mock-data": "^1.0.0",
+        "@visx/responsive": "^1.3.0",
         "@visx/scale": "^1.4.0",
         "@visx/scale": "^1.4.0",
         "@visx/shape": "^1.4.0",
         "@visx/shape": "^1.4.0",
         "@visx/tooltip": "^1.3.0",
         "@visx/tooltip": "^1.3.0",
         "ace-builds": "^1.4.12",
         "ace-builds": "^1.4.12",
         "axios": "^0.20.0",
         "axios": "^0.20.0",
+        "d3-array": "^2.11.0",
+        "d3-time-format": "^3.0.0",
         "dotenv": "^8.2.0",
         "dotenv": "^8.2.0",
         "ini": ">=1.3.6",
         "ini": ">=1.3.6",
         "js-base64": "^3.6.0",
         "js-base64": "^3.6.0",
@@ -548,6 +554,11 @@
       "resolved": "https://registry.npmjs.org/@types/classnames/-/classnames-2.2.11.tgz",
       "resolved": "https://registry.npmjs.org/@types/classnames/-/classnames-2.2.11.tgz",
       "integrity": "sha512-2koNhpWm3DgWRp5tpkiJ8JGc1xTn2q0l+jUNUE7oMKXUf5NpI9AIdC4kbjGNFBdHtcxBD18LAksoudAVhFKCjw=="
       "integrity": "sha512-2koNhpWm3DgWRp5tpkiJ8JGc1xTn2q0l+jUNUE7oMKXUf5NpI9AIdC4kbjGNFBdHtcxBD18LAksoudAVhFKCjw=="
     },
     },
+    "node_modules/@types/d3-array": {
+      "version": "2.9.0",
+      "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-2.9.0.tgz",
+      "integrity": "sha512-sdBMGfNvLUkBypPMEhOcKcblTQfgHbqbYrUqRE31jOwdDHBJBxz4co2MDAq93S4Cp++phk4UiwoEg/1hK3xXAQ=="
+    },
     "node_modules/@types/d3-color": {
     "node_modules/@types/d3-color": {
       "version": "1.4.1",
       "version": "1.4.1",
       "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-1.4.1.tgz",
       "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-1.4.1.tgz",
@@ -566,6 +577,11 @@
       "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.9.tgz",
       "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.9.tgz",
       "integrity": "sha512-NaIeSIBiFgSC6IGUBjZWcscUJEq7vpVu7KthHN8eieTV9d9MqkSOZLH4chq1PmcKy06PNe3axLeKmRIyxJ+PZQ=="
       "integrity": "sha512-NaIeSIBiFgSC6IGUBjZWcscUJEq7vpVu7KthHN8eieTV9d9MqkSOZLH4chq1PmcKy06PNe3axLeKmRIyxJ+PZQ=="
     },
     },
+    "node_modules/@types/d3-random": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-2.2.0.tgz",
+      "integrity": "sha512-Hjfj9m68NmYZzushzEG7etPvKH/nj9b9s9+qtkNG3/dbRBjQZQg1XS6nRuHJcCASTjxXlyXZnKu2gDxyQIIu9A=="
+    },
     "node_modules/@types/d3-scale": {
     "node_modules/@types/d3-scale": {
       "version": "3.2.2",
       "version": "3.2.2",
       "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-3.2.2.tgz",
       "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-3.2.2.tgz",
@@ -587,6 +603,11 @@
       "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-1.1.1.tgz",
       "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-1.1.1.tgz",
       "integrity": "sha512-ULX7LoqXTCYtM+tLYOaeAJK7IwCT+4Gxlm2MaH0ErKLi07R5lh8NHCAyWcDkCCmx1AfRcBEV6H9QE9R25uP7jw=="
       "integrity": "sha512-ULX7LoqXTCYtM+tLYOaeAJK7IwCT+4Gxlm2MaH0ErKLi07R5lh8NHCAyWcDkCCmx1AfRcBEV6H9QE9R25uP7jw=="
     },
     },
+    "node_modules/@types/d3-time-format": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-3.0.0.tgz",
+      "integrity": "sha512-UpLg1mn/8PLyjr+J/JwdQJM/GzysMvv2CS8y+WYAL5K0+wbvXv/pPSLEfdNaprCZsGcXTxPsFMy8QtkYv9ueew=="
+    },
     "node_modules/@types/glob": {
     "node_modules/@types/glob": {
       "version": "7.1.3",
       "version": "7.1.3",
       "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.3.tgz",
       "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.3.tgz",
@@ -1085,11 +1106,35 @@
         "prop-types": "^15.6.2"
         "prop-types": "^15.6.2"
       }
       }
     },
     },
+    "node_modules/@visx/mock-data": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/@visx/mock-data/-/mock-data-1.5.0.tgz",
+      "integrity": "sha512-J30eoqXUzA1uM6bdgB4q8fM59dG/UthiN2tvnvWyxxlPNbuYJmRAu0wBsUYYsUZtzgSAjwSImfHeyfb4tXot0g==",
+      "dependencies": {
+        "@types/d3-random": "^2.2.0",
+        "d3-random": "^2.2.2"
+      }
+    },
     "node_modules/@visx/point": {
     "node_modules/@visx/point": {
       "version": "1.0.0",
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/@visx/point/-/point-1.0.0.tgz",
       "resolved": "https://registry.npmjs.org/@visx/point/-/point-1.0.0.tgz",
       "integrity": "sha512-0L3ILwv6ro0DsQVbA1lo8fo6q3wvIeSTt9C8NarUUkoTNSFZaJtlmvwg2238r8fwwmSv0v9QFBj1hBz4o0bHrg=="
       "integrity": "sha512-0L3ILwv6ro0DsQVbA1lo8fo6q3wvIeSTt9C8NarUUkoTNSFZaJtlmvwg2238r8fwwmSv0v9QFBj1hBz4o0bHrg=="
     },
     },
+    "node_modules/@visx/responsive": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@visx/responsive/-/responsive-1.3.0.tgz",
+      "integrity": "sha512-RMfpjdnHKyhB/bb2i6x/vfQeYzfz+pJc3VUK+dP88lXXTkqv1O/NYIkXk2sWk6QhDw5muChHFmnZ1L8TnIOMXg==",
+      "dependencies": {
+        "@types/lodash": "^4.14.146",
+        "@types/react": "*",
+        "lodash": "^4.17.10",
+        "prop-types": "^15.6.1",
+        "resize-observer-polyfill": "1.5.1"
+      },
+      "peerDependencies": {
+        "react": "^15.0.0-0 || ^16.0.0-0"
+      }
+    },
     "node_modules/@visx/scale": {
     "node_modules/@visx/scale": {
       "version": "1.4.0",
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/@visx/scale/-/scale-1.4.0.tgz",
       "resolved": "https://registry.npmjs.org/@visx/scale/-/scale-1.4.0.tgz",
@@ -2721,6 +2766,11 @@
       "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz",
       "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz",
       "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="
       "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="
     },
     },
+    "node_modules/d3-random": {
+      "version": "2.2.2",
+      "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-2.2.2.tgz",
+      "integrity": "sha512-0D9P8TRj6qDAtHhRQn6EfdOtHMfsUWanl3yb/84C4DqpZ+VsgfI5iTVRNRbELCfNvRfpMr8OrqqUTQ6ANGCijw=="
+    },
     "node_modules/d3-scale": {
     "node_modules/d3-scale": {
       "version": "3.2.3",
       "version": "3.2.3",
       "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-3.2.3.tgz",
       "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-3.2.3.tgz",
@@ -7210,6 +7260,11 @@
       "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=",
       "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=",
       "dev": true
       "dev": true
     },
     },
+    "node_modules/resize-observer-polyfill": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
+      "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
+    },
     "node_modules/resolve-cwd": {
     "node_modules/resolve-cwd": {
       "version": "2.0.0",
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-2.0.0.tgz",
       "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-2.0.0.tgz",
@@ -10630,6 +10685,11 @@
       "resolved": "https://registry.npmjs.org/@types/classnames/-/classnames-2.2.11.tgz",
       "resolved": "https://registry.npmjs.org/@types/classnames/-/classnames-2.2.11.tgz",
       "integrity": "sha512-2koNhpWm3DgWRp5tpkiJ8JGc1xTn2q0l+jUNUE7oMKXUf5NpI9AIdC4kbjGNFBdHtcxBD18LAksoudAVhFKCjw=="
       "integrity": "sha512-2koNhpWm3DgWRp5tpkiJ8JGc1xTn2q0l+jUNUE7oMKXUf5NpI9AIdC4kbjGNFBdHtcxBD18LAksoudAVhFKCjw=="
     },
     },
+    "@types/d3-array": {
+      "version": "2.9.0",
+      "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-2.9.0.tgz",
+      "integrity": "sha512-sdBMGfNvLUkBypPMEhOcKcblTQfgHbqbYrUqRE31jOwdDHBJBxz4co2MDAq93S4Cp++phk4UiwoEg/1hK3xXAQ=="
+    },
     "@types/d3-color": {
     "@types/d3-color": {
       "version": "1.4.1",
       "version": "1.4.1",
       "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-1.4.1.tgz",
       "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-1.4.1.tgz",
@@ -10648,6 +10708,11 @@
       "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.9.tgz",
       "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.9.tgz",
       "integrity": "sha512-NaIeSIBiFgSC6IGUBjZWcscUJEq7vpVu7KthHN8eieTV9d9MqkSOZLH4chq1PmcKy06PNe3axLeKmRIyxJ+PZQ=="
       "integrity": "sha512-NaIeSIBiFgSC6IGUBjZWcscUJEq7vpVu7KthHN8eieTV9d9MqkSOZLH4chq1PmcKy06PNe3axLeKmRIyxJ+PZQ=="
     },
     },
+    "@types/d3-random": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-2.2.0.tgz",
+      "integrity": "sha512-Hjfj9m68NmYZzushzEG7etPvKH/nj9b9s9+qtkNG3/dbRBjQZQg1XS6nRuHJcCASTjxXlyXZnKu2gDxyQIIu9A=="
+    },
     "@types/d3-scale": {
     "@types/d3-scale": {
       "version": "3.2.2",
       "version": "3.2.2",
       "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-3.2.2.tgz",
       "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-3.2.2.tgz",
@@ -10669,6 +10734,11 @@
       "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-1.1.1.tgz",
       "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-1.1.1.tgz",
       "integrity": "sha512-ULX7LoqXTCYtM+tLYOaeAJK7IwCT+4Gxlm2MaH0ErKLi07R5lh8NHCAyWcDkCCmx1AfRcBEV6H9QE9R25uP7jw=="
       "integrity": "sha512-ULX7LoqXTCYtM+tLYOaeAJK7IwCT+4Gxlm2MaH0ErKLi07R5lh8NHCAyWcDkCCmx1AfRcBEV6H9QE9R25uP7jw=="
     },
     },
+    "@types/d3-time-format": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-3.0.0.tgz",
+      "integrity": "sha512-UpLg1mn/8PLyjr+J/JwdQJM/GzysMvv2CS8y+WYAL5K0+wbvXv/pPSLEfdNaprCZsGcXTxPsFMy8QtkYv9ueew=="
+    },
     "@types/glob": {
     "@types/glob": {
       "version": "7.1.3",
       "version": "7.1.3",
       "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.3.tgz",
       "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.3.tgz",
@@ -11144,11 +11214,32 @@
         "prop-types": "^15.6.2"
         "prop-types": "^15.6.2"
       }
       }
     },
     },
+    "@visx/mock-data": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/@visx/mock-data/-/mock-data-1.5.0.tgz",
+      "integrity": "sha512-J30eoqXUzA1uM6bdgB4q8fM59dG/UthiN2tvnvWyxxlPNbuYJmRAu0wBsUYYsUZtzgSAjwSImfHeyfb4tXot0g==",
+      "requires": {
+        "@types/d3-random": "^2.2.0",
+        "d3-random": "^2.2.2"
+      }
+    },
     "@visx/point": {
     "@visx/point": {
       "version": "1.0.0",
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/@visx/point/-/point-1.0.0.tgz",
       "resolved": "https://registry.npmjs.org/@visx/point/-/point-1.0.0.tgz",
       "integrity": "sha512-0L3ILwv6ro0DsQVbA1lo8fo6q3wvIeSTt9C8NarUUkoTNSFZaJtlmvwg2238r8fwwmSv0v9QFBj1hBz4o0bHrg=="
       "integrity": "sha512-0L3ILwv6ro0DsQVbA1lo8fo6q3wvIeSTt9C8NarUUkoTNSFZaJtlmvwg2238r8fwwmSv0v9QFBj1hBz4o0bHrg=="
     },
     },
+    "@visx/responsive": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@visx/responsive/-/responsive-1.3.0.tgz",
+      "integrity": "sha512-RMfpjdnHKyhB/bb2i6x/vfQeYzfz+pJc3VUK+dP88lXXTkqv1O/NYIkXk2sWk6QhDw5muChHFmnZ1L8TnIOMXg==",
+      "requires": {
+        "@types/lodash": "^4.14.146",
+        "@types/react": "*",
+        "lodash": "^4.17.10",
+        "prop-types": "^15.6.1",
+        "resize-observer-polyfill": "1.5.1"
+      }
+    },
     "@visx/scale": {
     "@visx/scale": {
       "version": "1.4.0",
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/@visx/scale/-/scale-1.4.0.tgz",
       "resolved": "https://registry.npmjs.org/@visx/scale/-/scale-1.4.0.tgz",
@@ -12623,6 +12714,11 @@
       "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz",
       "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz",
       "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="
       "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="
     },
     },
+    "d3-random": {
+      "version": "2.2.2",
+      "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-2.2.2.tgz",
+      "integrity": "sha512-0D9P8TRj6qDAtHhRQn6EfdOtHMfsUWanl3yb/84C4DqpZ+VsgfI5iTVRNRbELCfNvRfpMr8OrqqUTQ6ANGCijw=="
+    },
     "d3-scale": {
     "d3-scale": {
       "version": "3.2.3",
       "version": "3.2.3",
       "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-3.2.3.tgz",
       "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-3.2.3.tgz",
@@ -16461,6 +16557,11 @@
       "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=",
       "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=",
       "dev": true
       "dev": true
     },
     },
+    "resize-observer-polyfill": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
+      "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
+    },
     "resolve-cwd": {
     "resolve-cwd": {
       "version": "2.0.0",
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-2.0.0.tgz",
       "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-2.0.0.tgz",

+ 6 - 0
dashboard/package.json

@@ -5,6 +5,8 @@
   "dependencies": {
   "dependencies": {
     "@fullstory/browser": "^1.4.5",
     "@fullstory/browser": "^1.4.5",
     "@material-ui/core": "^4.11.3",
     "@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/js-yaml": "^3.12.5",
     "@types/lodash": "^4.14.165",
     "@types/lodash": "^4.14.165",
     "@types/markdown-to-jsx": "^6.11.3",
     "@types/markdown-to-jsx": "^6.11.3",
@@ -15,11 +17,15 @@
     "@visx/event": "^1.3.0",
     "@visx/event": "^1.3.0",
     "@visx/gradient": "^1.0.0",
     "@visx/gradient": "^1.0.0",
     "@visx/grid": "^1.4.0",
     "@visx/grid": "^1.4.0",
+    "@visx/mock-data": "^1.0.0",
+    "@visx/responsive": "^1.3.0",
     "@visx/scale": "^1.4.0",
     "@visx/scale": "^1.4.0",
     "@visx/shape": "^1.4.0",
     "@visx/shape": "^1.4.0",
     "@visx/tooltip": "^1.3.0",
     "@visx/tooltip": "^1.3.0",
     "ace-builds": "^1.4.12",
     "ace-builds": "^1.4.12",
     "axios": "^0.20.0",
     "axios": "^0.20.0",
+    "d3-array": "^2.11.0",
+    "d3-time-format": "^3.0.0",
     "dotenv": "^8.2.0",
     "dotenv": "^8.2.0",
     "ini": ">=1.3.6",
     "ini": ">=1.3.6",
     "js-base64": "^3.6.0",
     "js-base64": "^3.6.0",

+ 1 - 0
dashboard/src/App.tsx

@@ -7,6 +7,7 @@ type PropsType = {};
 
 
 type StateType = {};
 type StateType = {};
 
 
+
 export default class App extends Component<PropsType, StateType> {
 export default class App extends Component<PropsType, StateType> {
   render() {
   render() {
     return (
     return (

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

@@ -127,7 +127,8 @@ class Home extends Component<PropsType, StateType> {
 
 
   provisionDOCR = (integrationId: number, tier: string, callback?: any) => {
   provisionDOCR = (integrationId: number, tier: string, callback?: any) => {
     console.log("Provisioning DOCR...");
     console.log("Provisioning DOCR...");
-    return api.createDOCR(
+    return api
+    .createDOCR(
       "<token>",
       "<token>",
       {
       {
         do_integration_id: integrationId,
         do_integration_id: integrationId,
@@ -137,7 +138,8 @@ class Home extends Component<PropsType, StateType> {
       {
       {
         project_id: this.props.currentProject.id,
         project_id: this.props.currentProject.id,
       }
       }
-    );
+    )
+    .then(() => callback()); 
   };
   };
 
 
   provisionDOKS = (integrationId: number, region: string) => {
   provisionDOKS = (integrationId: number, region: string) => {

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

@@ -361,7 +361,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     // Append universal tabs
     // Append universal tabs
     tabOptions.push(
     tabOptions.push(
       { label: "Status", value: "status" },
       { label: "Status", value: "status" },
-      // { label: "Metrics", value: "metrics" },
+      //{ label: "Metrics", value: "metrics" },
       { label: "Chart Overview", value: "graph" }
       { label: "Chart Overview", value: "graph" }
     );
     );
 
 

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

@@ -219,7 +219,7 @@ export default class RevisionSection extends Component<PropsType, StateType> {
             ? `Current Revision`
             ? `Current Revision`
             : `Previewing Revision (Not Deployed)`}{" "}
             : `Previewing Revision (Not Deployed)`}{" "}
           - <Revision>No. {this.props.chart.version}</Revision>
           - <Revision>No. {this.props.chart.version}</Revision>
-          <i className="material-icons">expand_more</i>
+          <i className="material-icons">arrow_drop_down</i>
         </RevisionHeader>
         </RevisionHeader>
 
 
         <RevisionList>{this.renderExpanded()}</RevisionList>
         <RevisionList>{this.renderExpanded()}</RevisionList>

+ 18 - 30
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/AreaChart.tsx

@@ -1,4 +1,3 @@
-/*
 import React, { useMemo, useCallback } from 'react';
 import React, { useMemo, useCallback } from 'react';
 import { AreaClosed, Line, Bar } from '@visx/shape';
 import { AreaClosed, Line, Bar } from '@visx/shape';
 import appleStock, { AppleStock } from '@visx/mock-data/lib/mocks/appleStock';
 import appleStock, { AppleStock } from '@visx/mock-data/lib/mocks/appleStock';
@@ -12,13 +11,18 @@ import { LinearGradient } from '@visx/gradient';
 import { max, extent, bisector } from 'd3-array';
 import { max, extent, bisector } from 'd3-array';
 import { timeFormat } from 'd3-time-format';
 import { timeFormat } from 'd3-time-format';
 
 
+/*
+export const accentColor = '#f5cb42';
+export const accentColorDark = '#949eff';
+*/
+
 type TooltipData = AppleStock;
 type TooltipData = AppleStock;
 
 
 const stock = appleStock.slice(800);
 const stock = appleStock.slice(800);
-export const background = '#3b6978';
-export const background2 = '#204051';
-export const accentColor = '#edffea';
-export const accentColorDark = '#75daad';
+export const background = '#3b697800';
+export const background2 = '#20405100';
+export const accentColor = '#949eff';
+export const accentColorDark = '#949eff';
 const tooltipStyles = {
 const tooltipStyles = {
   ...defaultStyles,
   ...defaultStyles,
   background,
   background,
@@ -55,7 +59,7 @@ export default withTooltip<AreaProps, TooltipData>(
 
 
     // bounds
     // bounds
     const innerWidth = width - margin.left - margin.right;
     const innerWidth = width - margin.left - margin.right;
-    const innerHeight = height - margin.top - margin.bottom;
+    const innerHeight = height - margin.top - margin.bottom - 20;
 
 
     // scales
     // scales
     const dateScale = useMemo(
     const dateScale = useMemo(
@@ -109,25 +113,7 @@ export default withTooltip<AreaProps, TooltipData>(
             rx={14}
             rx={14}
           />
           />
           <LinearGradient id="area-background-gradient" from={background} to={background2} />
           <LinearGradient id="area-background-gradient" from={background} to={background2} />
-          <LinearGradient id="area-gradient" from={accentColor} to={accentColor} toOpacity={0.1} />
-          <GridRows
-            left={margin.left}
-            scale={stockValueScale}
-            width={innerWidth}
-            strokeDasharray="1,3"
-            stroke={accentColor}
-            strokeOpacity={0}
-            pointerEvents="none"
-          />
-          <GridColumns
-            top={margin.top}
-            scale={dateScale}
-            height={innerHeight}
-            strokeDasharray="1,3"
-            stroke={accentColor}
-            strokeOpacity={0.2}
-            pointerEvents="none"
-          />
+          <LinearGradient id="area-gradient" from={accentColor} to={accentColor} toOpacity={0} />
           <AreaClosed<AppleStock>
           <AreaClosed<AppleStock>
             data={stock}
             data={stock}
             x={d => dateScale(getDate(d)) ?? 0}
             x={d => dateScale(getDate(d)) ?? 0}
@@ -194,13 +180,16 @@ export default withTooltip<AreaProps, TooltipData>(
               {`$${getStockValue(tooltipData)}`}
               {`$${getStockValue(tooltipData)}`}
             </TooltipWithBounds>
             </TooltipWithBounds>
             <Tooltip
             <Tooltip
-              top={innerHeight + margin.top - 14}
+              top={-10}
               left={tooltipLeft}
               left={tooltipLeft}
               style={{
               style={{
                 ...defaultStyles,
                 ...defaultStyles,
-                minWidth: 72,
+                background: '#26272f',
+                color: '#aaaabb',
+                width: 100,
+                paddingTop: 35,
                 textAlign: 'center',
                 textAlign: 'center',
-                transform: 'translateX(-50%)',
+                transform: 'translateX(-60px)',
               }}
               }}
             >
             >
               {formatDate(getDate(tooltipData))}
               {formatDate(getDate(tooltipData))}
@@ -210,5 +199,4 @@ export default withTooltip<AreaProps, TooltipData>(
       </div>
       </div>
     );
     );
   },
   },
-);
-*/
+);

+ 153 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricsSection.tsx

@@ -1,25 +1,95 @@
 import React, { Component } from "react";
 import React, { Component } from "react";
 import styled from "styled-components";
 import styled from "styled-components";
+import ParentSize from '@visx/responsive/lib/components/ParentSize';
 
 
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 import { ChartType } from "shared/types";
 import { ChartType } from "shared/types";
 
 
-import Loading from "components/Loading";
+import TabSelector from "components/TabSelector";
+import AreaChart from "./AreaChart";
 
 
 type PropsType = {
 type PropsType = {
   currentChart: ChartType;
   currentChart: ChartType;
 };
 };
 
 
 type StateType = {
 type StateType = {
+  selectedRange: string,
+  selectedMetricLabel: string,
+  dropdownExpanded: boolean,
 };
 };
 
 
 export default class ListSection extends Component<PropsType, StateType> {
 export default class ListSection extends Component<PropsType, StateType> {
   state = {
   state = {
+    selectedRange: '1H',
+    selectedMetricLabel: 'CPU Utilization',
+    dropdownExpanded: false,
   }
   }
 
 
+  renderDropdown = () => {
+    if (this.state.dropdownExpanded) {
+      return (
+        <>
+          <DropdownOverlay onClick={() => this.setState({ dropdownExpanded: false })} />
+          <Dropdown
+            dropdownWidth='200px'
+            dropdownMaxHeight='200px'
+            onClick={() => this.setState({ dropdownExpanded: false })}
+          >
+            {this.renderOptionList()}
+          </Dropdown>
+        </>
+      );
+    }
+  };
+
+  renderOptionList = () => {
+    let metricOptions = [
+      { value: 'cpu', label: 'CPU Utilization' },
+      { value: 'ram', label: 'RAM Utilization' },
+    ];
+    return metricOptions.map(
+      (option: { value: string; label: string }, i: number) => {
+        return (
+          <Option
+            key={i}
+            selected={option.label === this.state.selectedMetricLabel}
+            onClick={() => this.setState({ selectedMetricLabel: option.label })}
+            lastItem={i === metricOptions.length - 1}
+          >
+            {option.label}
+          </Option>
+        );
+      }
+    );
+  };
+
   render() {
   render() {
     return (
     return (
       <StyledMetricsSection>
       <StyledMetricsSection>
+        <ParentSize>
+          {({ width, height }) => <AreaChart width={width} height={height} />}
+        </ParentSize>
+        <MetricSelector 
+          onClick={() => this.setState({ dropdownExpanded: !this.state.dropdownExpanded })}
+        >
+          {this.state.selectedMetricLabel}
+          <i className="material-icons">arrow_drop_down</i>
+          {this.renderDropdown()}
+        </MetricSelector>
+        <RangeWrapper>
+          <TabSelector
+            options={[
+              { value: '1H', label: '1H' }, 
+              { value: '1D', label: '1D' },
+              { value: '1M', label: '1M' }, 
+              { value: '3M', label: '3M' },
+              { value: '1Y', label: '1Y' }, 
+              { value: 'ALL', label: 'ALL' },
+            ]}
+            currentTab={this.state.selectedRange}
+            setCurrentTab={(x: string) => this.setState({ selectedRange: x })}
+          />
+        </RangeWrapper>
       </StyledMetricsSection>
       </StyledMetricsSection>
     );
     );
   }
   }
@@ -27,6 +97,88 @@ export default class ListSection extends Component<PropsType, StateType> {
 
 
 ListSection.contextType = Context;
 ListSection.contextType = Context;
 
 
+const DropdownOverlay = styled.div`
+  position: fixed;
+  width: 100%;
+  height: 100%;
+  z-index: 10;
+  left: 0px;
+  top: 0px;
+  cursor: default;
+`;
+
+const Option = styled.div`
+  width: 100%;
+  border-top: 1px solid #00000000;
+  border-bottom: 1px solid
+    ${(props: { selected: boolean; lastItem: boolean }) =>
+      props.lastItem ? "#ffffff00" : "#ffffff15"};
+  height: 37px;
+  font-size: 13px;
+  padding-top: 9px;
+  align-items: center;
+  padding-left: 15px;
+  cursor: pointer;
+  padding-right: 10px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  background: ${(props: { selected: boolean; lastItem: boolean }) =>
+    props.selected ? "#ffffff11" : ""};
+
+  :hover {
+    background: #ffffff22;
+  }
+`;
+
+const Dropdown = styled.div`
+  position: absolute;
+  left: 0;
+  top: calc(100% + 10px);
+  background: #26282f;
+  width: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
+    props.dropdownWidth};
+  max-height: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
+    props.dropdownMaxHeight || "300px"};
+  border-radius: 3px;
+  z-index: 999;
+  overflow-y: auto;
+  margin-bottom: 20px;
+  box-shadow: 0 4px 8px 0px #00000088;
+`;
+
+const RangeWrapper = styled.div`
+  position: absolute;
+  bottom: 10px;
+  font-weight: bold;
+  left: 0;
+  width: 100%;
+`;
+
+const MetricSelector = styled.div`
+  font-size: 16px;
+  font-weight: 500;
+  color: #ffffff;
+  position: absolute;
+  top: 0;
+  left: 5px;
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+  border-radius: 5px;
+  :hover {
+    > i {
+      background: #ffffff22;
+    }
+  }
+
+  > i {
+    border-radius: 20px;
+    font-size: 20px;
+    margin-left: 10px;
+  }
+`;
+
 const StyledMetricsSection = styled.div`
 const StyledMetricsSection = styled.div`
   width: 100%;
   width: 100%;
   height: 100%;
   height: 100%;

+ 8 - 3
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx

@@ -11,6 +11,7 @@ type PropsType = {
   selectPod: Function;
   selectPod: Function;
   isLast?: boolean;
   isLast?: boolean;
   isFirst?: boolean;
   isFirst?: boolean;
+  setPodError: (x: string) => void;
 };
 };
 
 
 type StateType = {
 type StateType = {
@@ -103,16 +104,18 @@ export default class ControllerTab extends Component<PropsType, StateType> {
   };
   };
 
 
   getPodStatus = (status: any) => {
   getPodStatus = (status: any) => {
-    if (status?.phase == "Pending" && status?.containerStatuses !== undefined) {
+    if (status?.phase === "Pending" && status?.containerStatuses !== undefined) {
       return status.containerStatuses[0].state.waiting.reason;
       return status.containerStatuses[0].state.waiting.reason;
       // return 'waiting'
       // return 'waiting'
+    } else if (status?.phase === "Pending") {
+      return "Pending"
     }
     }
 
 
-    if (status?.phase == "Failed") {
+    if (status?.phase === "Failed") {
       return "failed";
       return "failed";
     }
     }
 
 
-    if (status?.phase == "Running") {
+    if (status?.phase === "Running") {
       let collatedStatus = "running";
       let collatedStatus = "running";
 
 
       status?.containerStatuses?.forEach((s: any) => {
       status?.containerStatuses?.forEach((s: any) => {
@@ -151,6 +154,8 @@ export default class ControllerTab extends Component<PropsType, StateType> {
               key={pod.metadata?.name}
               key={pod.metadata?.name}
               selected={selectedPod?.metadata?.name === pod?.metadata?.name}
               selected={selectedPod?.metadata?.name === pod?.metadata?.name}
               onClick={() => {
               onClick={() => {
+                this.props.setPodError("");
+                (status === "failed" && pod.status?.message) && this.props.setPodError(pod.status?.message);
                 selectPod(pod);
                 selectPod(pod);
               }}
               }}
             >
             >

+ 5 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx

@@ -4,6 +4,7 @@ import { Context } from "shared/Context";
 
 
 type PropsType = {
 type PropsType = {
   selectedPod: any;
   selectedPod: any;
+  podError: string;
 };
 };
 
 
 type StateType = {
 type StateType = {
@@ -40,7 +41,7 @@ export default class Logs extends Component<PropsType, StateType> {
       return <Message>Please select a pod to view its logs.</Message>;
       return <Message>Please select a pod to view its logs.</Message>;
     }
     }
     if (this.state.logs.length == 0) {
     if (this.state.logs.length == 0) {
-      return <Message>No logs to display from this pod.</Message>;
+      return <Message>{this.props.podError || "No logs to display from this pod."}</Message>;
     }
     }
     return this.state.logs.map((log, i) => {
     return this.state.logs.map((log, i) => {
       return <Log key={i}>{log}</Log>;
       return <Log key={i}>{log}</Log>;
@@ -206,9 +207,11 @@ const LogStream = styled.div`
 const Message = styled.div`
 const Message = styled.div`
   display: flex;
   display: flex;
   height: 100%;
   height: 100%;
-  width: 100%;
+  width: calc(100% - 150px);
   align-items: center;
   align-items: center;
   justify-content: center;
   justify-content: center;
+  margin-left: 75px;
+  text-align: center;
   color: #ffffff44;
   color: #ffffff44;
   font-size: 13px;
   font-size: 13px;
 `;
 `;

+ 4 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx

@@ -20,6 +20,7 @@ type StateType = {
   selectedPod: any;
   selectedPod: any;
   controllers: any[];
   controllers: any[];
   loading: boolean;
   loading: boolean;
+  podError: string;
 };
 };
 
 
 export default class StatusSection extends Component<PropsType, StateType> {
 export default class StatusSection extends Component<PropsType, StateType> {
@@ -29,11 +30,13 @@ export default class StatusSection extends Component<PropsType, StateType> {
     selectedPod: {} as any,
     selectedPod: {} as any,
     controllers: [] as any[],
     controllers: [] as any[],
     loading: true,
     loading: true,
+    podError: "",
   };
   };
 
 
   renderLogs = () => {
   renderLogs = () => {
     return (
     return (
       <Logs
       <Logs
+        podError={this.state.podError}
         key={this.state.selectedPod?.metadata?.name}
         key={this.state.selectedPod?.metadata?.name}
         selectedPod={this.state.selectedPod}
         selectedPod={this.state.selectedPod}
       />
       />
@@ -56,6 +59,7 @@ export default class StatusSection extends Component<PropsType, StateType> {
           controller={c}
           controller={c}
           isLast={i === this.state.controllers.length - 1}
           isLast={i === this.state.controllers.length - 1}
           isFirst={i === 0}
           isFirst={i === 0}
+          setPodError={(x: string) => this.setState({ podError: x })}
         />
         />
       );
       );
     });
     });

+ 2 - 2
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -441,7 +441,7 @@ const StyledSidebar = styled.section`
   animation-fill-mode: forwards;
   animation-fill-mode: forwards;
   @keyframes showSidebar {
   @keyframes showSidebar {
     from {
     from {
-      margin-left: -220px;
+      margin-left: -200px;
     }
     }
     to {
     to {
       margin-left: 0px;
       margin-left: 0px;
@@ -452,7 +452,7 @@ const StyledSidebar = styled.section`
       margin-left: 0px;
       margin-left: 0px;
     }
     }
     to {
     to {
-      margin-left: -220px;
+      margin-left: -200px;
     }
     }
   }
   }
 `;
 `;

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

@@ -400,10 +400,10 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
           <>
           <>
             <Subtitle>
             <Subtitle>
               Select the container image you would like to connect to this
               Select the container image you would like to connect to this
-              template or
-              <Highlight onClick={() => this.setState({ sourceType: "repo" })}>
+              template
+              {/* <Highlight onClick={() => this.setState({ sourceType: "repo" })}>
                 link a git repository
                 link a git repository
-              </Highlight>
+              </Highlight> */}
               .<Required>*</Required>
               .<Required>*</Required>
             </Subtitle>
             </Subtitle>
             <DarkMatter />
             <DarkMatter />

+ 6 - 7
internal/auth/token/token.go

@@ -27,19 +27,18 @@ type Token struct {
 	IAt       *time.Time `json:"iat"`
 	IAt       *time.Time `json:"iat"`
 }
 }
 
 
-func GetTokenForUser(userID, projID uint) (*Token, error) {
-	if userID == 0 || projID == 0 {
+func GetTokenForUser(userID uint) (*Token, error) {
+	if userID == 0 {
 		return nil, fmt.Errorf("id cannot be 0")
 		return nil, fmt.Errorf("id cannot be 0")
 	}
 	}
 
 
 	iat := time.Now()
 	iat := time.Now()
 
 
 	return &Token{
 	return &Token{
-		SubKind:   User,
-		Sub:       fmt.Sprintf("%d", userID),
-		ProjectID: projID,
-		IBy:       userID,
-		IAt:       &iat,
+		SubKind: User,
+		Sub:     fmt.Sprintf("%d", userID),
+		IBy:     userID,
+		IAt:     &iat,
 	}, nil
 	}, nil
 }
 }
 
 

+ 1 - 1
internal/auth/token/token_test.go

@@ -13,7 +13,7 @@ func TestGetAndEncodeTokenForUser(t *testing.T) {
 		TokenSecret: "fakesecret",
 		TokenSecret: "fakesecret",
 	}
 	}
 
 
-	tok, err := token.GetTokenForUser(1, 1)
+	tok, err := token.GetTokenForUser(1)
 
 
 	if err != nil {
 	if err != nil {
 		t.Fatalf("%v\n", err)
 		t.Fatalf("%v\n", err)

+ 11 - 11
internal/integrations/ci/actions/actions.go

@@ -79,31 +79,31 @@ func (g *GithubActions) Setup() (string, error) {
 }
 }
 
 
 type GithubActionYAMLStep struct {
 type GithubActionYAMLStep struct {
-	Name string `yaml:"name"`
-	ID   string `yaml:"id"`
-	Uses string `yaml:"uses"`
-	Run  string `yaml:"run"`
+	Name string `yaml:"name,omitempty"`
+	ID   string `yaml:"id,omitempty"`
+	Uses string `yaml:"uses,omitempty"`
+	Run  string `yaml:"run,omitempty"`
 }
 }
 
 
 type GithubActionYAMLOnPushBranches struct {
 type GithubActionYAMLOnPushBranches struct {
-	Branches []string `yaml:"branches"`
+	Branches []string `yaml:"branches,omitempty"`
 }
 }
 
 
 type GithubActionYAMLOnPush struct {
 type GithubActionYAMLOnPush struct {
-	Push GithubActionYAMLOnPushBranches `yaml:"push"`
+	Push GithubActionYAMLOnPushBranches `yaml:"push,omitempty"`
 }
 }
 
 
 type GithubActionYAMLJob struct {
 type GithubActionYAMLJob struct {
-	RunsOn string                 `yaml:"runs-on"`
-	Steps  []GithubActionYAMLStep `yaml:"steps"`
+	RunsOn string                 `yaml:"runs-on,omitempty"`
+	Steps  []GithubActionYAMLStep `yaml:"steps,omitempty"`
 }
 }
 
 
 type GithubActionYAML struct {
 type GithubActionYAML struct {
-	On GithubActionYAMLOnPush `yaml:"on"`
+	On GithubActionYAMLOnPush `yaml:"on,omitempty"`
 
 
-	Name string `yaml:"name"`
+	Name string `yaml:"name,omitempty"`
 
 
-	Jobs map[string]GithubActionYAMLJob `yaml:"jobs"`
+	Jobs map[string]GithubActionYAMLJob `yaml:"jobs,omitempty"`
 }
 }
 
 
 func (g *GithubActions) GetGithubActionYAML() ([]byte, error) {
 func (g *GithubActions) GetGithubActionYAML() ([]byte, error) {

+ 21 - 0
internal/models/auth_code.go

@@ -0,0 +1,21 @@
+package models
+
+import (
+	"time"
+
+	"gorm.io/gorm"
+)
+
+// AuthCode type that extends gorm.Model
+type AuthCode struct {
+	gorm.Model
+
+	Token             string `gorm:"unique"`
+	AuthorizationCode string `gorm:"unique"`
+	Expiry            *time.Time
+}
+
+func (a *AuthCode) IsExpired() bool {
+	timeLeft := a.Expiry.Sub(time.Now())
+	return timeLeft < 0
+}

+ 11 - 0
internal/repository/auth_code.go

@@ -0,0 +1,11 @@
+package repository
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// AuthCodeRepository represents the set of queries on the AuthCode model
+type AuthCodeRepository interface {
+	CreateAuthCode(a *models.AuthCode) (*models.AuthCode, error)
+	ReadAuthCode(code string) (*models.AuthCode, error)
+}

+ 37 - 0
internal/repository/gorm/auth_code.go

@@ -0,0 +1,37 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// AuthCodeRepository uses gorm.DB for querying the database
+type AuthCodeRepository struct {
+	db *gorm.DB
+}
+
+// NewAuthCodeRepository returns a AuthCodeRepository which uses
+// gorm.DB for querying the database
+func NewAuthCodeRepository(db *gorm.DB) repository.AuthCodeRepository {
+	return &AuthCodeRepository{db}
+}
+
+// CreateAuthCode creates a new auth code
+func (repo *AuthCodeRepository) CreateAuthCode(a *models.AuthCode) (*models.AuthCode, error) {
+	if err := repo.db.Create(a).Error; err != nil {
+		return nil, err
+	}
+	return a, nil
+}
+
+// ReadAuthCode gets an invite specified by a unique token
+func (repo *AuthCodeRepository) ReadAuthCode(code string) (*models.AuthCode, error) {
+	a := &models.AuthCode{}
+
+	if err := repo.db.Where("authorization_code = ?", code).First(&a).Error; err != nil {
+		return nil, err
+	}
+
+	return a, nil
+}

+ 1 - 0
internal/repository/gorm/repository.go

@@ -20,6 +20,7 @@ func NewRepository(db *gorm.DB, key *[32]byte) *repository.Repository {
 		Infra:            NewInfraRepository(db, key),
 		Infra:            NewInfraRepository(db, key),
 		GitActionConfig:  NewGitActionConfigRepository(db),
 		GitActionConfig:  NewGitActionConfigRepository(db),
 		Invite:           NewInviteRepository(db),
 		Invite:           NewInviteRepository(db),
+		AuthCode:         NewAuthCodeRepository(db),
 		KubeIntegration:  NewKubeIntegrationRepository(db, key),
 		KubeIntegration:  NewKubeIntegrationRepository(db, key),
 		BasicIntegration: NewBasicIntegrationRepository(db, key),
 		BasicIntegration: NewBasicIntegrationRepository(db, key),
 		OIDCIntegration:  NewOIDCIntegrationRepository(db, key),
 		OIDCIntegration:  NewOIDCIntegrationRepository(db, key),

+ 54 - 0
internal/repository/memory/auth_code.go

@@ -0,0 +1,54 @@
+package test
+
+import (
+	"errors"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// AuthCodeRepository uses gorm.DB for querying the database
+type AuthCodeRepository struct {
+	canQuery  bool
+	authCodes []*models.AuthCode
+}
+
+// NewAuthCodeRepository returns a AuthCodeRepository which uses
+// gorm.DB for querying the database
+func NewAuthCodeRepository(canQuery bool) repository.AuthCodeRepository {
+	return &AuthCodeRepository{canQuery, []*models.AuthCode{}}
+}
+
+// CreateAuthCode creates a new invite
+func (repo *AuthCodeRepository) CreateAuthCode(a *models.AuthCode) (*models.AuthCode, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	repo.authCodes = append(repo.authCodes, a)
+	a.ID = uint(len(repo.authCodes))
+
+	return a, nil
+}
+
+// ReadAuthCode gets an auth code object specified by the unique code
+func (repo *AuthCodeRepository) ReadAuthCode(code string) (*models.AuthCode, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	var res *models.AuthCode
+
+	for _, a := range repo.authCodes {
+		if code == a.AuthorizationCode {
+			res = a
+		}
+	}
+
+	if res == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	return res, nil
+}

+ 1 - 0
internal/repository/memory/repository.go

@@ -16,6 +16,7 @@ func NewRepository(canQuery bool) *repository.Repository {
 		Registry:         NewRegistryRepository(canQuery),
 		Registry:         NewRegistryRepository(canQuery),
 		GitRepo:          NewGitRepoRepository(canQuery),
 		GitRepo:          NewGitRepoRepository(canQuery),
 		Invite:           NewInviteRepository(canQuery),
 		Invite:           NewInviteRepository(canQuery),
+		AuthCode:         NewAuthCodeRepository(canQuery),
 		KubeIntegration:  NewKubeIntegrationRepository(canQuery),
 		KubeIntegration:  NewKubeIntegrationRepository(canQuery),
 		BasicIntegration: NewBasicIntegrationRepository(canQuery),
 		BasicIntegration: NewBasicIntegrationRepository(canQuery),
 		OIDCIntegration:  NewOIDCIntegrationRepository(canQuery),
 		OIDCIntegration:  NewOIDCIntegrationRepository(canQuery),

+ 1 - 0
internal/repository/repository.go

@@ -13,6 +13,7 @@ type Repository struct {
 	Infra            InfraRepository
 	Infra            InfraRepository
 	GitActionConfig  GitActionConfigRepository
 	GitActionConfig  GitActionConfigRepository
 	Invite           InviteRepository
 	Invite           InviteRepository
+	AuthCode         AuthCodeRepository
 	KubeIntegration  KubeIntegrationRepository
 	KubeIntegration  KubeIntegrationRepository
 	BasicIntegration BasicIntegrationRepository
 	BasicIntegration BasicIntegrationRepository
 	OIDCIntegration  OIDCIntegrationRepository
 	OIDCIntegration  OIDCIntegrationRepository

+ 102 - 0
server/api/user_handler.go

@@ -3,15 +3,20 @@ package api
 import (
 import (
 	"encoding/json"
 	"encoding/json"
 	"errors"
 	"errors"
+	"fmt"
+	"math/rand"
 	"net/http"
 	"net/http"
+	"net/url"
 	"strconv"
 	"strconv"
 	"strings"
 	"strings"
+	"time"
 
 
 	"golang.org/x/crypto/bcrypt"
 	"golang.org/x/crypto/bcrypt"
 
 
 	"gorm.io/gorm"
 	"gorm.io/gorm"
 
 
 	"github.com/go-chi/chi"
 	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/internal/auth/token"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/repository"
@@ -105,6 +110,103 @@ func (app *App) HandleAuthCheck(w http.ResponseWriter, r *http.Request) {
 	}
 	}
 }
 }
 
 
+// HandleCLILoginUser verifies that a user is logged in, and generates an access
+// token for usage from the CLI
+func (app *App) HandleCLILoginUser(w http.ResponseWriter, r *http.Request) {
+	queryParams, _ := url.ParseQuery(r.URL.RawQuery)
+
+	redirect := queryParams["redirect"][0]
+
+	session, err := app.Store.Get(r, app.ServerConf.CookieName)
+
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	userID, _ := session.Values["user_id"].(uint)
+
+	// generate the token
+	jwt, err := token.GetTokenForUser(userID)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	encoded, err := jwt.EncodeToken(&token.TokenGeneratorConf{
+		TokenSecret: app.ServerConf.TokenGeneratorSecret,
+	})
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	// generate 64 characters long authorization code
+	const letters = "abcdefghijklmnopqrstuvwxyz123456789"
+	code := make([]byte, 64)
+
+	for i := range code {
+		code[i] = letters[rand.Intn(len(letters))]
+	}
+
+	expiry := time.Now().Add(30 * time.Second)
+
+	// create auth code object and send back authorization code
+	authCode := &models.AuthCode{
+		Token:             encoded,
+		AuthorizationCode: string(code),
+		Expiry:            &expiry,
+	}
+
+	authCode, err = app.Repo.AuthCode.CreateAuthCode(authCode)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	http.Redirect(w, r, fmt.Sprintf("%s/?code=%s", redirect, url.QueryEscape(authCode.AuthorizationCode)), 302)
+}
+
+type ExchangeRequest struct {
+	AuthorizationCode string `json:"authorization_code"`
+}
+
+type ExchangeResponse struct {
+	Token string `json:"token"`
+}
+
+// HandleCLILoginExchangeToken exchanges an authorization code for a token
+func (app *App) HandleCLILoginExchangeToken(w http.ResponseWriter, r *http.Request) {
+	// read the request body and look up the authorization token
+	req := &ExchangeRequest{}
+
+	if err := json.NewDecoder(r.Body).Decode(req); err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	authCode, err := app.Repo.AuthCode.ReadAuthCode(req.AuthorizationCode)
+
+	if err != nil || authCode.IsExpired() {
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+
+	res := &ExchangeResponse{
+		Token: authCode.Token,
+	}
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(res); err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+}
+
 // HandleLoginUser checks the request header for cookie and validates the user.
 // HandleLoginUser checks the request header for cookie and validates the user.
 func (app *App) HandleLoginUser(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleLoginUser(w http.ResponseWriter, r *http.Request) {
 	session, err := app.Store.Get(r, app.ServerConf.CookieName)
 	session, err := app.Store.Get(r, app.ServerConf.CookieName)

+ 22 - 12
server/router/middleware/auth.go

@@ -64,7 +64,12 @@ func (auth *Auth) BasicAuthenticateWithRedirect(next http.Handler) http.Handler
 			}
 			}
 
 
 			// need state parameter to validate when redirected
 			// need state parameter to validate when redirected
-			session.Values["redirect"] = r.URL.Path
+			if r.URL.RawQuery == "" {
+				session.Values["redirect"] = r.URL.Path
+			} else {
+				session.Values["redirect"] = r.URL.Path + "?" + r.URL.RawQuery
+			}
+
 			session.Save(r, w)
 			session.Save(r, w)
 
 
 			http.Redirect(w, r, "/dashboard", 302)
 			http.Redirect(w, r, "/dashboard", 302)
@@ -182,23 +187,28 @@ func (auth *Auth) DoesUserHaveProjectAccess(
 		// first check for token
 		// first check for token
 		tok := auth.getTokenFromRequest(r)
 		tok := auth.getTokenFromRequest(r)
 
 
+		var userID uint
+
 		if tok != nil && tok.ProjectID == uint(projID) {
 		if tok != nil && tok.ProjectID == uint(projID) {
 			next.ServeHTTP(w, r)
 			next.ServeHTTP(w, r)
 			return
 			return
-		}
-
-		session, err := auth.store.Get(r, auth.cookieName)
+		} else if tok != nil {
+			userID = tok.IBy
+		} else {
+			session, err := auth.store.Get(r, auth.cookieName)
 
 
-		if err != nil {
-			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
-			return
-		}
+			if err != nil {
+				http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+				return
+			}
 
 
-		userID, ok := session.Values["user_id"].(uint)
+			sessionUserID, ok := session.Values["user_id"]
+			userID = sessionUserID.(uint)
 
 
-		if !ok {
-			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
-			return
+			if !ok {
+				http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+				return
+			}
 		}
 		}
 
 
 		// get the project
 		// get the project

+ 14 - 0
server/router/router.go

@@ -62,6 +62,20 @@ func New(a *api.App) *chi.Mux {
 			),
 			),
 		)
 		)
 
 
+		r.Method(
+			"GET",
+			"/cli/login",
+			auth.BasicAuthenticateWithRedirect(
+				requestlog.NewHandler(a.HandleCLILoginUser, l),
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/cli/login/exchange",
+			requestlog.NewHandler(a.HandleCLILoginExchangeToken, l),
+		)
+
 		r.Method(
 		r.Method(
 			"POST",
 			"POST",
 			"/login",
 			"/login",