소스 검색

Merge branch 'beta.3.cli-github-login' of https://github.com/porter-dev/porter into beta.3.metrics

jusrhee 5 년 전
부모
커밋
732c3c4001

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

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

+ 66 - 11
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",
@@ -10654,9 +10709,9 @@
       "integrity": "sha512-NaIeSIBiFgSC6IGUBjZWcscUJEq7vpVu7KthHN8eieTV9d9MqkSOZLH4chq1PmcKy06PNe3axLeKmRIyxJ+PZQ=="
       "integrity": "sha512-NaIeSIBiFgSC6IGUBjZWcscUJEq7vpVu7KthHN8eieTV9d9MqkSOZLH4chq1PmcKy06PNe3axLeKmRIyxJ+PZQ=="
     },
     },
     "@types/d3-random": {
     "@types/d3-random": {
-      "version": "1.1.3",
-      "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-1.1.3.tgz",
-      "integrity": "sha512-XXR+ZbFCoOd4peXSMYJzwk0/elP37WWAzS/DG+90eilzVbUSsgKhBcWqylGWe+lA2ubgr7afWAOBaBxRgMUrBQ=="
+      "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",
@@ -11160,12 +11215,12 @@
       }
       }
     },
     },
     "@visx/mock-data": {
     "@visx/mock-data": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/@visx/mock-data/-/mock-data-1.0.0.tgz",
-      "integrity": "sha512-yd4lult1oEpmbj7pxzNb398VW+fRYaZWBFFKcqJEibAxlko4kmBfVqFR2gPZTp/K7I0/5mvD3hhNr1NpH2SIHA==",
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/@visx/mock-data/-/mock-data-1.5.0.tgz",
+      "integrity": "sha512-J30eoqXUzA1uM6bdgB4q8fM59dG/UthiN2tvnvWyxxlPNbuYJmRAu0wBsUYYsUZtzgSAjwSImfHeyfb4tXot0g==",
       "requires": {
       "requires": {
-        "@types/d3-random": "^1.1.2",
-        "d3-random": "^1.0.3"
+        "@types/d3-random": "^2.2.0",
+        "d3-random": "^2.2.2"
       }
       }
     },
     },
     "@visx/point": {
     "@visx/point": {
@@ -12660,9 +12715,9 @@
       "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="
       "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="
     },
     },
     "d3-random": {
     "d3-random": {
-      "version": "1.1.2",
-      "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-1.1.2.tgz",
-      "integrity": "sha512-6AK5BNpIFqP+cx/sreKzNjWbwZQCSUatxq+pPRmFIQaWuoD+NrbVWw7YWpHiXpCQ/NanKdtGDuB+VQcZDaEmYQ=="
+      "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",

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

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

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

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

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