فهرست منبع

resolved conflict

jusrhee 4 سال پیش
والد
کامیت
09f0cde3f4
57فایلهای تغییر یافته به همراه1320 افزوده شده و 330 حذف شده
  1. 14 0
      api/server/handlers/project/create.go
  2. 7 0
      api/server/handlers/project/delete.go
  3. 41 0
      api/server/handlers/project/get_billing.go
  4. 52 0
      api/server/handlers/project/get_onboarding.go
  5. 4 3
      api/server/handlers/project/get_usage.go
  6. 77 0
      api/server/handlers/project/update_onboarding.go
  7. 17 10
      api/server/handlers/release/create.go
  8. 7 6
      api/server/router/middleware/usage.go
  9. 83 0
      api/server/router/project.go
  10. 2 2
      api/server/shared/apitest/config.go
  11. 4 0
      api/server/shared/config/config.go
  12. 1 0
      api/server/shared/config/env/envconfs.go
  13. 1 1
      api/server/shared/config/envloader/envloader.go
  14. 6 1
      api/server/shared/config/loader/init_ee.go
  15. 20 3
      api/server/shared/config/loader/loader.go
  16. 30 0
      api/types/project.go
  17. 1 1
      cli/cmd/pack/pack.go
  18. 1 0
      cmd/migrate/keyrotate/helpers_test.go
  19. 2 2
      cmd/migrate/main.go
  20. 2 2
      cmd/ready/main.go
  21. 11 0
      dashboard/src/main/home/Home.tsx
  22. 21 1
      dashboard/src/main/home/ModalHandler.tsx
  23. 17 4
      dashboard/src/main/home/dashboard/Dashboard.tsx
  24. 1 1
      dashboard/src/main/home/launch/Launch.tsx
  25. 82 78
      dashboard/src/main/home/modals/UsageWarningModal.tsx
  26. 7 6
      dashboard/src/main/home/onboarding/Onboarding.tsx
  27. 1 1
      dashboard/src/main/home/onboarding/state/StepHandler.ts
  28. 104 3
      dashboard/src/main/home/onboarding/state/index.ts
  29. 2 2
      dashboard/src/main/home/onboarding/steps/ConnectRegistry/ConnectRegistry.tsx
  30. 101 53
      dashboard/src/main/home/onboarding/steps/ConnectSource.tsx
  31. 0 2
      dashboard/src/main/home/onboarding/steps/NewProject.tsx
  32. 3 1
      dashboard/src/main/home/onboarding/types.ts
  33. 16 15
      dashboard/src/main/home/project-settings/BillingPage.tsx
  34. 47 33
      dashboard/src/main/home/project-settings/InviteList.tsx
  35. 30 5
      dashboard/src/main/home/project-settings/ProjectSettings.tsx
  36. 27 8
      dashboard/src/main/home/provisioner/ProvisionerSettings.tsx
  37. 31 1
      dashboard/src/shared/Context.tsx
  38. 6 0
      dashboard/src/shared/api.tsx
  39. 1 0
      dashboard/src/shared/error_handling/window_error_handling.ts
  40. 15 14
      dashboard/src/shared/types.tsx
  41. 95 4
      ee/billing/ironplans.go
  42. 16 2
      ee/billing/types.go
  43. 3 2
      ee/usage/limit.go
  44. 7 0
      internal/billing/billing.go
  45. 33 0
      internal/models/onboarding.go
  46. 2 2
      internal/models/usage.go
  47. 1 0
      internal/repository/gorm/helpers_test.go
  48. 1 0
      internal/repository/gorm/migrate.go
  49. 52 0
      internal/repository/gorm/onboarding.go
  50. 1 1
      internal/repository/gorm/release.go
  51. 6 0
      internal/repository/gorm/repository.go
  52. 10 0
      internal/repository/onboarding.go
  53. 1 0
      internal/repository/repository.go
  54. 75 0
      internal/repository/test/onboarding.go
  55. 7 0
      internal/repository/test/repository.go
  56. 29 9
      internal/usage/usage.go
  57. 86 51
      services/usage/usage.go

+ 14 - 0
api/server/handlers/project/create.go

@@ -51,6 +51,20 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
+	// create default project usage restriction
+	_, err = p.Repo().ProjectUsage().CreateProjectUsage(&models.ProjectUsage{
+		ProjectID:      proj.ID,
+		ResourceCPU:    types.BasicPlan.ResourceCPU,
+		ResourceMemory: types.BasicPlan.ResourceMemory,
+		Clusters:       types.BasicPlan.Clusters,
+		Users:          types.BasicPlan.Users,
+	})
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	p.WriteResult(w, r, proj.ToProjectType())
 
 	// add project to billing team

+ 7 - 0
api/server/handlers/project/delete.go

@@ -35,4 +35,11 @@ func (p *ProjectDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	}
 
 	p.WriteResult(w, r, proj.ToProjectType())
+
+	// delete the billing team
+	if err := p.Config().BillingManager.DeleteTeam(proj); err != nil {
+		// we do not write error response, since setting up billing error can be
+		// resolved later and may not be fatal
+		p.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
+	}
 }

+ 41 - 0
api/server/handlers/project/get_billing.go

@@ -0,0 +1,41 @@
+package project
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type ProjectGetBillingHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewProjectGetBillingHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *ProjectGetBillingHandler {
+	return &ProjectGetBillingHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (p *ProjectGetBillingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	res := &types.GetProjectBillingResponse{
+		HasBilling: false,
+	}
+
+	if sc := p.Config().ServerConf; sc.IronPlansAPIKey != "" && sc.IronPlansServerURL != "" {
+		// determine if the project has usage attached; if so, set has_billing to true
+		usage, _ := p.Repo().ProjectUsage().ReadProjectUsage(proj.ID)
+
+		res.HasBilling = usage != nil
+	}
+
+	p.WriteResult(w, r, res)
+}

+ 52 - 0
api/server/handlers/project/get_onboarding.go

@@ -0,0 +1,52 @@
+package project
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type OnboardingGetHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewOnboardingGetHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *OnboardingGetHandler {
+	return &OnboardingGetHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *OnboardingGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	// look for onboarding
+	onboarding, err := p.Repo().Onboarding().ReadProjectOnboarding(proj.ID)
+	isNotFound := errors.Is(gorm.ErrRecordNotFound, err)
+
+	if isNotFound {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("project onboarding data not found"),
+			http.StatusNotFound,
+		))
+
+		return
+	} else if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// return onboarding data type
+	p.WriteResult(w, r, onboarding.ToOnboardingType())
+}

+ 4 - 3
api/server/handlers/project/get_usage.go

@@ -31,9 +31,10 @@ func (p *ProjectGetUsageHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 	res := &types.GetProjectUsageResponse{}
 
 	currUsage, limit, usageCache, err := usage.GetUsage(&usage.GetUsageOpts{
-		Project: proj,
-		DOConf:  p.Config().DOConf,
-		Repo:    p.Repo(),
+		Project:          proj,
+		DOConf:           p.Config().DOConf,
+		Repo:             p.Repo(),
+		WhitelistedUsers: p.Config().WhitelistedUsers,
 	})
 
 	if err != nil {

+ 77 - 0
api/server/handlers/project/update_onboarding.go

@@ -0,0 +1,77 @@
+package project
+
+import (
+	"errors"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type OnboardingUpdateHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewOnboardingUpdateHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *OnboardingUpdateHandler {
+	return &OnboardingUpdateHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *OnboardingUpdateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	request := &types.UpdateOnboardingRequest{}
+
+	if ok := p.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	// look for onboarding
+	onboarding, err := p.Repo().Onboarding().ReadProjectOnboarding(proj.ID)
+	isNotFound := errors.Is(gorm.ErrRecordNotFound, err)
+
+	if err != nil && !isNotFound {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if isNotFound {
+		onboarding = &models.Onboarding{
+			ProjectID: proj.ID,
+		}
+	}
+
+	onboarding.CurrentStep = request.CurrentStep
+	onboarding.ConnectedSource = request.ConnectedSource
+	onboarding.SkipRegistryConnection = request.SkipRegistryConnection
+	onboarding.SkipResourceProvision = request.SkipResourceProvision
+	onboarding.RegistryConnectionID = request.RegistryConnectionID
+	onboarding.RegistryInfraID = request.RegistryInfraID
+	onboarding.ClusterInfraID = request.ClusterInfraID
+
+	if isNotFound {
+		// if not found, create onboarding struct
+		onboarding, err = p.Repo().Onboarding().CreateProjectOnboarding(onboarding)
+	} else {
+		// otherwise, update the onboarding model
+		onboarding, err = p.Repo().Onboarding().UpdateProjectOnboarding(onboarding)
+	}
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// return onboarding data type
+	p.WriteResult(w, r, onboarding.ToOnboardingType())
+}

+ 17 - 10
api/server/handlers/release/create.go

@@ -259,23 +259,26 @@ func createGitAction(
 		DryRun:                 release == nil,
 	}
 
-	workflowYAML, err := gaRunner.Setup()
-
-	if err != nil {
-		return nil, nil, err
-	}
+	// Save the github err for after creating the git action config. However, we
+	// need to call Setup() in order to get the workflow file before writing the
+	// action config, in the case of a dry run, since the dry run does not create
+	// a git action config.
+	workflowYAML, githubErr := gaRunner.Setup()
 
 	if gaRunner.DryRun {
+		if githubErr != nil {
+			return nil, nil, githubErr
+		}
+
 		return nil, workflowYAML, nil
 	}
 
 	// handle write to the database
 	ga, err := config.Repo.GitActionConfig().CreateGitActionConfig(&models.GitActionConfig{
-		ReleaseID:    release.ID,
-		GitRepo:      request.GitRepo,
-		GitBranch:    request.GitBranch,
-		ImageRepoURI: request.ImageRepoURI,
-		// TODO: github installation id here?
+		ReleaseID:      release.ID,
+		GitRepo:        request.GitRepo,
+		GitBranch:      request.GitBranch,
+		ImageRepoURI:   request.ImageRepoURI,
 		GitRepoID:      request.GitRepoID,
 		DockerfilePath: request.DockerfilePath,
 		FolderPath:     request.FolderPath,
@@ -296,6 +299,10 @@ func createGitAction(
 		return nil, nil, err
 	}
 
+	if githubErr != nil {
+		return nil, nil, githubErr
+	}
+
 	return ga.ToGitActionConfigType(), workflowYAML, nil
 }
 

+ 7 - 6
api/server/router/middleware/usage.go

@@ -28,9 +28,10 @@ func (b *UsageMiddleware) Middleware(next http.Handler) http.Handler {
 
 		// get the project usage limits
 		currentUsage, limit, _, err := usage.GetUsage(&usage.GetUsageOpts{
-			Project: proj,
-			DOConf:  b.config.DOConf,
-			Repo:    b.config.Repo,
+			Project:          proj,
+			DOConf:           b.config.DOConf,
+			Repo:             b.config.Repo,
+			WhitelistedUsers: b.config.WhitelistedUsers,
 		})
 
 		if err != nil {
@@ -73,9 +74,9 @@ func allowUsage(
 ) bool {
 	switch metric {
 	case types.Users:
-		return plan.Users > current.Users+1
+		return plan.Users == 0 || plan.Users >= current.Users+1
 	case types.Clusters:
-		return plan.Clusters > current.Clusters+1
+		return plan.Clusters == 0 || plan.Clusters >= current.Clusters+1
 	default:
 		return false
 	}
@@ -93,7 +94,7 @@ func getMetricUsage(
 	case types.Users:
 		return plan.Users, current.Users
 	case types.Clusters:
-		return plan.Users, current.Users
+		return plan.Clusters, current.Clusters
 	default:
 		return 0, 0
 	}

+ 83 - 0
api/server/router/project.go

@@ -138,6 +138,62 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/onboarding -> project.NewProjectGetOnboardingHandler
+	getOnboardingEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/onboarding",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	getOnboardingHandler := project.NewOnboardingGetHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getOnboardingEndpoint,
+		Handler:  getOnboardingHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/onboarding -> project.NewProjectGetOnboardingHandler
+	updateOnboardingEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/onboarding",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	updateOnboardingHandler := project.NewOnboardingUpdateHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: updateOnboardingEndpoint,
+		Handler:  updateOnboardingHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/usage -> project.NewProjectGetUsageHandler
 	getUsageEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
@@ -165,6 +221,33 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/billing -> project.NewProjectGetBillingHandler
+	getBillingEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/billing",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	getBillingHandler := project.NewProjectGetBillingHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getBillingEndpoint,
+		Handler:  getBillingHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/billing/token -> billing.NewBillingGetTokenEndpoint
 	getBillingTokenEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 2 - 2
api/server/shared/apitest/config.go

@@ -5,7 +5,7 @@ import (
 	"testing"
 
 	"github.com/porter-dev/porter/api/server/shared/config"
-	"github.com/porter-dev/porter/api/server/shared/config/loader"
+	"github.com/porter-dev/porter/api/server/shared/config/envloader"
 	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/auth/sessionstore"
 	"github.com/porter-dev/porter/internal/auth/token"
@@ -27,7 +27,7 @@ func (t *TestConfigLoader) LoadConfig() (*config.Config, error) {
 	l := logger.New(true, os.Stdout)
 	repo := test.NewRepository(t.canQuery, t.failingRepoMethods...)
 
-	envConf, err := loader.FromEnv()
+	envConf, err := envloader.FromEnv()
 
 	if err != nil {
 		return nil, err

+ 4 - 0
api/server/shared/config/config.go

@@ -86,6 +86,10 @@ type Config struct {
 
 	// BillingManager manages billing for Porter instances with billing enabled
 	BillingManager billing.BillingManager
+
+	// WhitelistedUsers do not count toward usage limits
+	WhitelistedUsers map[uint]uint
+
 	// PowerDNSClient is a client for PowerDNS, if the Porter instance supports vanity URLs
 	PowerDNSClient *powerdns.Client
 }

+ 1 - 0
api/server/shared/config/env/envconfs.go

@@ -51,6 +51,7 @@ type ServerConf struct {
 
 	IronPlansAPIKey    string `env:"IRON_PLANS_API_KEY"`
 	IronPlansServerURL string `env:"IRON_PLANS_SERVER_URL"`
+	WhitelistedUsers   []uint `env:"WHITELISTED_USERS"`
 
 	DOClientID                 string `env:"DO_CLIENT_ID"`
 	DOClientSecret             string `env:"DO_CLIENT_SECRET"`

+ 1 - 1
api/server/shared/config/loader/envloader.go → api/server/shared/config/envloader/envloader.go

@@ -1,4 +1,4 @@
-package loader
+package envloader
 
 import (
 	"fmt"

+ 6 - 1
api/server/shared/config/loader/init_ee.go

@@ -28,8 +28,13 @@ func init() {
 	if InstanceEnvConf.ServerConf.IronPlansAPIKey != "" && InstanceEnvConf.ServerConf.IronPlansServerURL != "" {
 		serverURL := InstanceEnvConf.ServerConf.IronPlansServerURL
 		apiKey := InstanceEnvConf.ServerConf.IronPlansAPIKey
+		var err error
 
-		InstanceBillingManager = eeBilling.NewClient(serverURL, apiKey, eeRepo)
+		InstanceBillingManager, err = eeBilling.NewClient(serverURL, apiKey, eeRepo)
+
+		if err != nil {
+			panic(err)
+		}
 	} else {
 		InstanceBillingManager = &billing.NoopBillingManager{}
 	}

+ 20 - 3
api/server/shared/config/loader/loader.go

@@ -9,6 +9,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/apierrors/alerter"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config/env"
+	"github.com/porter-dev/porter/api/server/shared/config/envloader"
 	"github.com/porter-dev/porter/api/server/shared/websocket"
 	"github.com/porter-dev/porter/internal/adapter"
 	"github.com/porter-dev/porter/internal/analytics"
@@ -30,7 +31,7 @@ import (
 )
 
 var InstanceBillingManager billing.BillingManager
-var InstanceEnvConf *EnvConf
+var InstanceEnvConf *envloader.EnvConf
 var InstanceDB *pgorm.DB
 
 type EnvConfigLoader struct {
@@ -42,8 +43,15 @@ func NewEnvLoader(version string) config.ConfigLoader {
 }
 
 func sharedInit() {
-	InstanceEnvConf, _ = FromEnv()
-	InstanceDB, _ = adapter.New(InstanceEnvConf.DBConf)
+	var err error
+	InstanceEnvConf, _ = envloader.FromEnv()
+
+	InstanceDB, err = adapter.New(InstanceEnvConf.DBConf)
+
+	if err != nil {
+		panic(err)
+	}
+
 	InstanceBillingManager = &billing.NoopBillingManager{}
 }
 
@@ -181,6 +189,15 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 		},
 	}
 
+	// construct the whitelisted users map
+	wlUsers := make(map[uint]uint)
+
+	for _, userID := range sc.WhitelistedUsers {
+		wlUsers[userID] = userID
+	}
+
+	res.WhitelistedUsers = wlUsers
+
 	res.URLCache = urlcache.Init(sc.DefaultApplicationHelmRepoURL, sc.DefaultAddonHelmRepoURL)
 
 	provAgent, err := getProvisionerAgent(sc)

+ 30 - 0
api/types/project.go

@@ -65,3 +65,33 @@ type DeleteRoleResponse struct {
 type GetBillingTokenResponse struct {
 	Token string `json:"token"`
 }
+
+type GetProjectBillingResponse struct {
+	HasBilling bool `json:"has_billing"`
+}
+
+type StepEnum string
+
+const (
+	StepGithub StepEnum = "github"
+	StepTwo    StepEnum = "step_two"
+)
+
+type ConnectedSourceType string
+
+const (
+	ConnectedSourceTypeGithub = "github"
+	ConnectedSourceTypeDocker = "docker"
+)
+
+type OnboardingData struct {
+	CurrentStep            StepEnum            `json:"current_step"`
+	ConnectedSource        ConnectedSourceType `json:"connected_source"`
+	SkipRegistryConnection bool                `json:"skip_registry_connection"`
+	SkipResourceProvision  bool                `json:"skip_resource_provision"`
+	RegistryConnectionID   uint                `json:"registry_connection_id"`
+	RegistryInfraID        uint                `json:"registry_infra_id"`
+	ClusterInfraID         uint                `json:"cluster_infra_id"`
+}
+
+type UpdateOnboardingRequest OnboardingData

+ 1 - 1
cli/cmd/pack/pack.go

@@ -31,7 +31,7 @@ func (a *Agent) Build(opts *docker.BuildOpts) error {
 	buildOpts := pack.BuildOptions{
 		RelativeBaseDir: filepath.Dir(absPath),
 		Image:           fmt.Sprintf("%s:%s", opts.ImageRepo, opts.Tag),
-		Builder:         "heroku/buildpacks:18",
+		Builder:         "paketobuildpacks/builder:full",
 		AppPath:         opts.BuildContext,
 		TrustBuilder:    true,
 		Env:             opts.Env,

+ 1 - 0
cmd/migrate/keyrotate/helpers_test.go

@@ -67,6 +67,7 @@ func setupTestEnv(tester *tester, t *testing.T) {
 		&models.ClusterResolver{},
 		&models.Infra{},
 		&models.GitActionConfig{},
+		&models.Onboarding{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

+ 2 - 2
cmd/migrate/main.go

@@ -3,7 +3,7 @@ package main
 import (
 	"log"
 
-	"github.com/porter-dev/porter/api/server/shared/config/loader"
+	"github.com/porter-dev/porter/api/server/shared/config/envloader"
 	"github.com/porter-dev/porter/cmd/migrate/keyrotate"
 
 	adapter "github.com/porter-dev/porter/internal/adapter"
@@ -17,7 +17,7 @@ func main() {
 	logger := lr.NewConsole(true)
 	logger.Info().Msg("running migrations")
 
-	envConf, err := loader.FromEnv()
+	envConf, err := envloader.FromEnv()
 
 	if err != nil {
 		logger.Fatal().Err(err).Msg("could not load env conf")

+ 2 - 2
cmd/ready/main.go

@@ -5,14 +5,14 @@ import (
 	"net/http"
 	"os"
 
-	"github.com/porter-dev/porter/api/server/shared/config/loader"
+	"github.com/porter-dev/porter/api/server/shared/config/envloader"
 	lr "github.com/porter-dev/porter/internal/logger"
 )
 
 func main() {
 	logger := lr.NewConsole(true)
 
-	envConf, err := loader.FromEnv()
+	envConf, err := envloader.FromEnv()
 
 	if err != nil {
 		logger.Fatal().Err(err).Msg("")

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

@@ -287,12 +287,22 @@ class Home extends Component<PropsType, StateType> {
     this.getMetadata();
   }
 
+  async checkIfProjectHasBilling(projectId: number) {
+    const res = await api.getHasBilling(
+      "<token>",
+      {},
+      { project_id: projectId }
+    );
+    this.context.setHasBillingEnabled(res.data?.has_billing);
+  }
+
   // TODO: Need to handle the following cases. Do a deep rearchitecture (Prov -> Dashboard?) if need be:
   // 1. Make sure clicking cluster in drawer shows cluster-dashboard
   // 2. Make sure switching projects shows appropriate initial view (dashboard || provisioner)
   // 3. Make sure initializing from URL (DO oauth) displays the appropriate initial view
   componentDidUpdate(prevProps: PropsType) {
     if (prevProps.currentProject?.id !== this.props.currentProject?.id) {
+      this.checkIfProjectHasBilling(this?.context?.currentProject?.id);
       api
         .getUsage(
           "<token>",
@@ -301,6 +311,7 @@ class Home extends Component<PropsType, StateType> {
         )
         .then((res) => {
           const usage = res.data;
+          this.context.setUsage(usage);
           if (usage.exceeded) {
             this.context.setCurrentModal("UsageWarningModal", {
               usage,

+ 21 - 1
dashboard/src/main/home/ModalHandler.tsx

@@ -11,6 +11,8 @@ import DeleteNamespaceModal from "./modals/DeleteNamespaceModal";
 import EditInviteOrCollaboratorModal from "./modals/EditInviteOrCollaboratorModal";
 import AccountSettingsModal from "./modals/AccountSettingsModal";
 
+import UsageWarningModal from "./modals/UsageWarningModal";
+
 const ModalHandler: React.FC<{
   setRefreshClusters: (x: boolean) => void;
 }> = ({ setRefreshClusters }) => {
@@ -38,6 +40,7 @@ const ModalHandler: React.FC<{
           onRequestClose={() => setCurrentModal(null, null)}
           width="760px"
           height="650px"
+          title="Connecting to an Existing Cluster"
         >
           <ClusterInstructionsModal />
         </Modal>
@@ -50,6 +53,7 @@ const ModalHandler: React.FC<{
             onRequestClose={() => setCurrentModal(null, null)}
             width="565px"
             height="275px"
+            title="Cluster Settings"
           >
             <UpdateClusterModal
               setRefreshClusters={(x: boolean) => setRefreshClusters(x)}
@@ -60,7 +64,8 @@ const ModalHandler: React.FC<{
         <Modal
           onRequestClose={() => setCurrentModal(null, null)}
           width="760px"
-          height="725px"
+          height="380px"
+          title="Add a New Integration"
         >
           <IntegrationsModal />
         </Modal>
@@ -70,6 +75,7 @@ const ModalHandler: React.FC<{
           onRequestClose={() => setCurrentModal(null, null)}
           width="760px"
           height="650px"
+          title="Connecting to an Image Registry"
         >
           <IntegrationsInstructionsModal />
         </Modal>
@@ -80,6 +86,7 @@ const ModalHandler: React.FC<{
             onRequestClose={() => setCurrentModal(null, null)}
             width="600px"
             height="220px"
+            title="Add Namespace"
           >
             <NamespaceModal />
           </Modal>
@@ -90,6 +97,7 @@ const ModalHandler: React.FC<{
             onRequestClose={() => setCurrentModal(null, null)}
             width="700px"
             height="280px"
+            title="Delete Namespace"
           >
             <DeleteNamespaceModal />
           </Modal>
@@ -109,10 +117,22 @@ const ModalHandler: React.FC<{
           onRequestClose={() => setCurrentModal(null, null)}
           width="760px"
           height="440px"
+          title="Account Settings"
         >
           <AccountSettingsModal />
         </Modal>
       )}
+
+      {currentModal === "UsageWarningModal" && (
+        <Modal
+          onRequestClose={() => setCurrentModal(null, null)}
+          width="760px"
+          height="530px"
+          title="Usage Warning"
+        >
+          <UsageWarningModal />
+        </Modal>
+      )}
     </>
   );
 };

+ 17 - 4
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -107,11 +107,23 @@ class Dashboard extends Component<PropsType, StateType> {
     if (this.currentTab() === "provisioner") {
       return <Provisioner setRefreshClusters={this.props.setRefreshClusters} />;
     } else if (this.currentTab() === "create-cluster") {
+      let helperText = "Create a cluster to link to this project";
+      let helperIcon = "info";
+      let helperColor = "white";
+      if (
+        this.context.hasBillingEnabled &&
+        this.context.usage.current.clusters >= this.context.usage.limit.clusters
+      ) {
+        helperText =
+          "You need to update your billing to provision or connect a new cluster";
+        helperIcon = "warning";
+        helperColor = "#f5cb42";
+      }
       return (
         <>
-          <Banner>
-            <i className="material-icons">info</i>
-            Create a cluster to link to this project.
+          <Banner color={helperColor}>
+            <i className="material-icons">{helperIcon}</i>
+            {helperText}
           </Banner>
           <ProvisionerSettings infras={this.state.infras} provisioner={true} />
         </>
@@ -212,7 +224,7 @@ const DashboardWrapper = styled.div`
   padding-bottom: 100px;
 `;
 
-const Banner = styled.div`
+const Banner = styled.div<{ color: string }>`
   height: 40px;
   width: 100%;
   margin: 5px 0 30px;
@@ -222,6 +234,7 @@ const Banner = styled.div`
   padding-left: 15px;
   align-items: center;
   background: #ffffff11;
+  color: ${(props) => props.color};
   > i {
     margin-right: 10px;
     font-size: 18px;

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

@@ -325,7 +325,7 @@ class Templates extends Component<PropsType, StateType> {
         <TemplatesWrapper>
           <TitleSection>
             Launch
-            <a href="https://docs.porter.run/docs/applications" target="_blank">
+            <a href="https://docs.porter.run/docs/addons" target="_blank">
               <i className="material-icons">help_outline</i>
             </a>
           </TitleSection>

+ 82 - 78
dashboard/src/main/home/modals/UsageWarningModal.tsx

@@ -4,108 +4,112 @@ import styled from "styled-components";
 import Banner from "components/Banner";
 
 import { Context } from "shared/Context";
-import { UsageData } from "shared/types";
+import { Usage, UsageData } from "shared/types";
 import { Link } from "react-router-dom";
 
+type UsageKeys = keyof Usage;
+
 const ReadableNameMap: {
   [key: string]: string;
 } = {
-  resource_cpu: "CPU",
-  resource_memory: "Memory",
-  clusters: "Cluster number",
-  users: "Users on your team",
+  resource_cpu: "CPU Usage",
+  resource_memory: "Memory Usage",
+  clusters: "Clusters",
+  users: "Users",
 };
 
-const filterExceeded = (usage: UsageData) => {
-  const current = usage.current;
-  const limits = usage.limit;
-  return Object.keys(usage.current).reduce((acc, key) => {
-    if (!acc.current) {
-      acc.current = {} as any;
-    }
-    if (!acc.limit) {
-      acc.limit = {} as any;
-    }
-    if (current[key] > limits[key]) {
-      acc.current[key] = current[key];
-      acc.limit[key] = limits[key];
-    }
-    return acc;
-  }, {} as Partial<UsageData>);
+const parseToReadableString = (
+  key: UsageKeys,
+  current: number,
+  limit: number
+) => {
+  switch (key) {
+    case "clusters":
+      return `${current} / ${limit} clusters`;
+    case `resource_cpu`:
+      return `${current} / ${limit} vCPU`;
+    case "resource_memory":
+      return `${current / 1000} / ${limit / 1000} GB`;
+    case "users":
+      return `${current} / ${limit} seats`;
+    default:
+      return `${current} / ${limit}`;
+  }
+};
+
+const getRemainingDays = (date: string) => {
+  const start = new Date(date);
+
+  const _second = 1000;
+  const _minute = _second * 60;
+  const _hour = _minute * 60;
+  const _day = _hour * 24;
+  const end = new Date(date);
+  end.setDate(end.getDate() + 7);
+
+  let distance = end.getTime() - start.getTime();
+
+  if (distance < 0) {
+    return;
+  }
+  const days = Math.floor(distance / _day);
+  const hours = Math.floor((distance % _day) / _hour);
+  const minutes = Math.floor((distance % _hour) / _minute);
+  if (days > 0) return `${days} ${days > 1 ? "days" : "day"}`;
+  if (hours > 0) return `${hours} ${hours > 1 ? "hours" : "hour"}`;
+  return `${minutes} ${minutes > 1 ? "minutes" : "minute"}`;
 };
 
 const UpgradeChartModal: React.FC<{}> = () => {
   const { setCurrentModal, currentModalData } = useContext(Context);
   const [usage, setUsage] = useState<UsageData>(null);
-  const [filteredUsage, setFilteredUsage] = useState<Partial<UsageData>>(null);
+
   useEffect(() => {
     if (currentModalData.usage) {
       const currentUsage: UsageData = currentModalData.usage;
-      console.log(currentModalData);
       setUsage(currentUsage);
     }
   }, [currentModalData?.usage]);
 
-  useEffect(() => {
-    if (usage) {
-      setFilteredUsage(usage);
-    }
-  }, [usage]);
-
-  if (!usage || !filteredUsage) {
+  if (!usage) {
     return null;
   }
-  console.log({ usage, filteredUsage });
+
   return (
     <>
       <Br />
-        <Banner type="warning">
-          Your project is currently exceeding its resource usage limit.
-        </Banner>
+      <Banner type="warning">
+        Your project is currently exceeding its resource usage limit.
+      </Banner>
       <Br />
-      {
-        filteredUsage !== null && (
-          <UsageSection>
-            <UsageBlock isRed={filteredUsage.current["resource_cpu"] > filteredUsage.limit["resource_cpu"]}>
-              <Label isRed={filteredUsage.current["resource_cpu"] > filteredUsage.limit["resource_cpu"]}>
-                CPU Usage
-              </Label>
-              <Stat isRed={filteredUsage.current["resource_cpu"] > filteredUsage.limit["resource_cpu"]}>
-                {filteredUsage.current["resource_cpu"]} / {filteredUsage.limit["resource_cpu"]} vCPU
-              </Stat>
-            </UsageBlock>
-            <UsageBlock isRed={filteredUsage.current["resource_memory"] > filteredUsage.limit["resource_memory"]}>
-              <Label isRed={filteredUsage.current["resource_memory"] > filteredUsage.limit["resource_memory"]}>
-                Memory Usage
-              </Label>
-              <Stat isRed={filteredUsage.current["resource_memory"] > filteredUsage.limit["resource_memory"]}>
-                {filteredUsage.current["resource_memory"]/1000} / {filteredUsage.limit["resource_memory"]/1000} GB
-              </Stat>
-            </UsageBlock>
-            <UsageBlock isRed={filteredUsage.current["users"] > filteredUsage.limit["users"]}>
-              <Label isRed={filteredUsage.current["users"] > filteredUsage.limit["users"]}>
-                Users
-              </Label>
-              <Stat isRed={filteredUsage.current["users"] > filteredUsage.limit["users"]}>
-                {filteredUsage.current["users"]} / {filteredUsage.limit["users"]} seats
-              </Stat>
-            </UsageBlock>
-            <UsageBlock isRed={filteredUsage.current["clusters"] > filteredUsage.limit["clusters"]}>
-              <Label isRed={filteredUsage.current["clusters"] > filteredUsage.limit["clusters"]}>
-                Clusters
-              </Label> 
-              <Stat isRed={filteredUsage.current["clusters"] > filteredUsage.limit["clusters"]}>
-                {filteredUsage.current["clusters"]} / {filteredUsage.limit["clusters"]} clusters
-              </Stat>
-            </UsageBlock>
-          </UsageSection>
-        )
-      }
+      {usage !== null && (
+        <UsageSection>
+          {Object.keys(usage.current).map((key) => {
+            const label = ReadableNameMap[key];
+            const current = usage.current[key];
+            const limit = usage.limit[key];
+            const isExceeding = current > limit;
+            return (
+              <UsageBlock isRed={isExceeding}>
+                <Label isRed={isExceeding}>{label}</Label>
+                <Stat isRed={isExceeding}>
+                  {parseToReadableString(key as UsageKeys, current, limit)}
+                </Stat>
+              </UsageBlock>
+            );
+          })}
+        </UsageSection>
+      )}
       <Helper>
-        You have <b>7 days</b> to resolve this issue before your access to the dashboard is restricted.
+        You have <b>{getRemainingDays(usage.exceeded_since)}</b> to resolve this
+        issue before your access to the dashboard is restricted.
       </Helper>
       <Helper>
-        Have a question about billing? Email us at <a target="_blank" href="mailto:contact@porter.run">contact@porter.run</a>.
+        Have a question about billing? Email us at{" "}
+        <a target="_blank" href="mailto:contact@porter.run">
+          contact@porter.run
+        </a>
+        .
       </Helper>
       <Button
         as={Link}
@@ -124,7 +128,7 @@ const UpgradeChartModal: React.FC<{}> = () => {
 export default UpgradeChartModal;
 
 const UsageBlock = styled.div<{ isRed?: boolean }>`
-  border: 1px solid ${props => props.isRed ? "#ff385d" : "#ffffff55"};
+  border: 1px solid ${(props) => (props.isRed ? "#ff385d" : "#ffffff55")};
   border-radius: 5px;
   padding: 18px;
 `;
@@ -137,13 +141,13 @@ const Helper = styled.div`
 const Label = styled.div<{ isRed?: boolean }>`
   margin-bottom: 10px;
   font-weight: 500;
-  color: ${props => props.isRed ? "#ff385d" : "#ffffff55"};
+  color: ${(props) => (props.isRed ? "#ff385d" : "#ffffff55")};
 `;
 
 const Stat = styled.div<{ isRed?: boolean }>`
   font-size: 20px;
   margin-bottom: 25px;
-  color: ${props => props.isRed ? "#ff385d" : "#ffffff55"};
+  color: ${(props) => (props.isRed ? "#ff385d" : "#ffffff55")};
 `;
 
 const Br = styled.div`
@@ -182,4 +186,4 @@ const UsageSection = styled.div`
   grid-column-gap: 25px;
   grid-row-gap: 25px;
   grid-template-columns: repeat(2, minmax(200px, 1fr));
-`;
+`;

+ 7 - 6
dashboard/src/main/home/onboarding/Onboarding.tsx

@@ -10,6 +10,7 @@ import { OFState } from "./state";
 import { useSteps } from "./state/StepHandler";
 
 const Onboarding = () => {
+  const context = useContext(Context);
   useSteps();
 
   useEffect(() => {
@@ -19,12 +20,12 @@ const Onboarding = () => {
     };
   }, []);
 
-  // useEffect(() => {
-  //   OFState.actions.initializeState(context.currentProject?.id);
-  //   return () => {
-  //     OFState.actions.clearState();
-  //   };
-  // }, [context.currentProject?.id]);
+  useEffect(() => {
+    OFState.actions.initializeState(context.currentProject?.id);
+    return () => {
+      OFState.actions.clearState();
+    };
+  }, [context.currentProject?.id]);
 
   // useEffect(() => {
   //   if (snap.StepHandler.finishedOnboarding) {

+ 1 - 1
dashboard/src/main/home/onboarding/state/StepHandler.ts

@@ -175,7 +175,7 @@ type StepHandlerType = {
   actions: {
     nextStep: (action?: Action) => void;
     clearState: () => void;
-    restoreState: (prevState: StepHandlerType) => void;
+    restoreState: (prevState: Partial<StepHandlerType>) => void;
     getStep: (nextStepName: string) => Step | SubStep;
   };
 };

+ 104 - 3
dashboard/src/main/home/onboarding/state/index.ts

@@ -1,5 +1,6 @@
 import { proxy, subscribe } from "valtio";
 import { devtools, subscribeKey } from "valtio/utils";
+import { Onboarding } from "../types";
 import { StateHandler } from "./StateHandler";
 import { Action, StepHandler } from "./StepHandler";
 
@@ -28,7 +29,7 @@ export const OFState = proxy({
       StepHandler.actions.clearState();
     },
     saveState: () => {
-      const state = JSON.stringify(OFState);
+      const state = compressState(OFState);
       localStorage.setItem(
         `onboarding-${OFState.StateHandler.project?.id}`,
         state
@@ -41,9 +42,9 @@ export const OFState = proxy({
       if (!notParsedPrevState) {
         return;
       }
-      const prevState = JSON.parse(notParsedPrevState);
+      const prevState = decompressState(notParsedPrevState);
 
-      if (prevState.StepHandler.finishedOnboarding) {
+      if (prevState.StepHandler.currentStepName === "clean_up") {
         return;
       }
 
@@ -56,3 +57,103 @@ export const OFState = proxy({
     },
   },
 });
+
+const compressState = (state: typeof OFState) => {
+  const currentStep = state.StepHandler?.currentStepName;
+  const project = state.StateHandler?.project;
+  const source = state.StateHandler?.connected_source;
+  const registry = state.StateHandler?.connected_registry;
+  const provision = state.StateHandler?.provision_resources;
+
+  let onboarding_state: Onboarding = {
+    current_step: currentStep,
+
+    project_id: project?.id,
+    project_name: project?.name,
+
+    connected_source: source,
+    skip_registry_connection: registry?.skip,
+
+    registry_connection_provider: registry?.provider,
+    registry_connection_credentials_id: registry?.credentials?.id,
+    registry_connection_settings_url:
+      registry?.settings?.gcr_url || registry?.settings?.registry_url,
+    registry_connection_settings_name: registry?.settings?.registry_name,
+
+    skip_resource_provision: provision?.skip,
+    resource_provision_provider: provision?.provider,
+    resource_provision_credentials_id: provision?.credentials?.id,
+    resource_provision_credentials_arn: provision?.credentials?.arn,
+    resource_provision_credentials_region: provision?.credentials?.region,
+
+    resource_provision_settings_cluster_name: provision?.settings?.cluster_name,
+    resource_provision_settings_region: provision?.settings?.region,
+    resource_provision_settings_tier: provision?.settings?.tier,
+    resource_provision_settings_machine_type:
+      provision?.settings?.aws_machine_type,
+  };
+
+  return JSON.stringify(onboarding_state);
+};
+
+const decompressState = (prev_state: string) => {
+  const state: Onboarding = JSON.parse(prev_state);
+
+  const step = state.current_step;
+  const project = {
+    id: state.project_id,
+    name: state.project_name,
+  };
+
+  let registry: any = {
+    skip: state.skip_registry_connection,
+    provider: state.registry_connection_provider,
+    credentials: {
+      id: state.registry_connection_credentials_id,
+    },
+    settings: {
+      registry_name: state.registry_connection_settings_name,
+    },
+  };
+
+  if (registry.provider === "gcp") {
+    registry.settings.gcr_url = state.registry_connection_settings_url;
+  } else if (registry.provider === "do") {
+    registry.settings.registry_url = state.registry_connection_settings_url;
+  }
+
+  let provision: any = {
+    skip: state.skip_resource_provision,
+    provider: state.resource_provision_provider,
+    credentials: {
+      id: state.resource_provision_credentials_id,
+    },
+    settings: {
+      cluster_name: state.resource_provision_settings_cluster_name,
+    },
+  };
+
+  if (provision.provider === "gcp") {
+    provision.credentials.region = state.resource_provision_credentials_region;
+  } else if (provision.provider === "aws") {
+    provision.credentials.region = state.resource_provision_credentials_region;
+    provision.credentials.arn = state.resource_provision_credentials_arn;
+    provision.settings.aws_machine_type =
+      state.resource_provision_settings_machine_type;
+  } else if (provision.provider === "do") {
+    provision.settings.tier = state.resource_provision_settings_tier;
+    provision.settings.region = state.resource_provision_settings_region;
+  }
+
+  return {
+    StepHandler: {
+      currentStepName: step,
+    },
+    StateHandler: {
+      project,
+      connected_source: state.connected_source,
+      connected_registry: registry,
+      provision_resources: provision,
+    },
+  };
+};

+ 2 - 2
dashboard/src/main/home/onboarding/steps/ConnectRegistry/ConnectRegistry.tsx

@@ -34,7 +34,7 @@ const ConnectRegistry: React.FC<{
   const { step } = useParams<any>();
 
   return (
-    <>
+    <div>
       <TitleSection>Getting Started</TitleSection>
       <Subtitle>Step 2 of 3</Subtitle>
       <Helper>
@@ -72,7 +72,7 @@ const ConnectRegistry: React.FC<{
           />
         </>
       )}
-    </>
+    </div>
   );
 };
 

+ 101 - 53
dashboard/src/main/home/onboarding/steps/ConnectSource.tsx

@@ -7,6 +7,7 @@ import api from "shared/api";
 import { useRouting } from "shared/routing";
 import styled from "styled-components";
 import { OFState } from "../state";
+import github from "assets/github.png";
 
 interface GithubAppAccessData {
   username?: string;
@@ -63,60 +64,104 @@ const ConnectSource: React.FC<{
   };
 
   return (
-    <>
-      <TitleSection>Getting Started</TitleSection>
-      <Subtitle>Step 1 of 3</Subtitle>
-      <Helper>
-        To deploy applications from your repo, you need to connect a Github
-        account
-      </Helper>
-      {!isLoading && (!accountData || !accountData?.accounts?.length) && (
-        <>
-          <ConnectToGithubButton href="/api/integrations/github-app/oauth">
-            Connect to github
-          </ConnectToGithubButton>
-          <Helper>
-            No thanks, I want to deploy from a{" "}
-            <A onClick={() => nextStep("docker")}>Docker registry</A>
-          </Helper>
-        </>
-      )}
-      {!isLoading && accountData?.accounts.length && (
-        <>
-          <List>
-            {accountData?.accounts.map((name, i) => {
-              return (
-                <Row key={i} isLastItem={i === accountData.accounts.length - 1}>
-                  <i className="material-icons">bookmark</i>
-                  {name}
-                </Row>
-              );
-            })}
-          </List>
-          <br />
-          Don't see the right repos?{" "}
-          <A href={"/api/integrations/github-app/install"}>
-            Install Porter in more repositories
-          </A>
-          <NextStep
-            text="Continue"
-            disabled={false}
-            onClick={() => nextStep("github")}
-            status={""}
-            makeFlush={true}
-            clearPosition={true}
-            statusPosition="right"
-            saveText=""
-            successText="Project created successfully!"
-          />
-        </>
-      )}
-    </>
+    <div>
+      <FadeWrapper>
+        <TitleSection>Getting Started</TitleSection>
+      </FadeWrapper>
+      <FadeWrapper delay="0.5s">
+        <Subtitle>Step 1 of 3 - Connect to GitHub</Subtitle>
+      </FadeWrapper>
+      <SlideWrapper delay="1.0s">
+        <Helper>
+          To deploy applications from your repo, you need to connect a Github
+          account.
+        </Helper>
+        {!isLoading && (!accountData || !accountData?.accounts?.length) && (
+          <>
+            <ConnectToGithubButton href="/api/integrations/github-app/oauth">
+              <GitHubIcon src={github} /> Connect to GitHub
+            </ConnectToGithubButton>
+            <Helper>
+              No thanks, I want to deploy from a
+              <A onClick={() => nextStep("docker")}>Docker registry</A>.
+            </Helper>
+          </>
+        )}
+        {!isLoading && accountData?.accounts.length && (
+          <>
+            <List>
+              {accountData?.accounts.map((name, i) => {
+                return (
+                  <Row key={i} isLastItem={i === accountData.accounts.length - 1}>
+                    <i className="material-icons">bookmark</i>
+                    {name}
+                  </Row>
+                );
+              })}
+            </List>
+            <br />
+            Don't see the right repos?{" "}
+            <A href={"/api/integrations/github-app/install"}>
+              Install Porter in more repositories
+            </A>
+            <NextStep
+              text="Continue"
+              disabled={false}
+              onClick={() => nextStep("github")}
+              status={""}
+              makeFlush={true}
+              clearPosition={true}
+              statusPosition="right"
+              saveText=""
+              successText="Project created successfully!"
+            />
+          </>
+        )}
+      </SlideWrapper>
+    </div>
   );
 };
 
 export default ConnectSource;
 
+const FadeWrapper = styled.div<{ delay?: string }>`
+  opacity: 0;
+  animation: fadeIn 0.5s ${(props) => props.delay || "0.2s"};
+  animation-fill-mode: forwards;
+
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const SlideWrapper = styled.div<{ delay?: string }>`
+  opacity: 0;
+  animation: slideIn 0.7s ${props => props.delay || "1.3s"};
+  animation-fill-mode: forwards;
+
+  @keyframes slideIn {
+    from {
+      opacity: 0;
+      transform: translateX(30px);
+    }
+    to {
+      opacity: 1;
+      transform: translateX(0);
+    }
+  }
+`;
+
+const GitHubIcon = styled.img`
+  width: 20px;
+  filter: brightness(150%);
+  margin-right: 10px;
+`;
+
 const NextStep = styled(SaveButton)`
   margin-top: 24px;
 `;
@@ -153,13 +198,14 @@ const Row = styled.div<{ isLastItem?: boolean }>`
   }
 `;
 
-const Subtitle = styled(TitleSection)`
+const Subtitle = styled.div`
   font-size: 16px;
+  font-weight: 500;
   margin-top: 16px;
 `;
 
 const ConnectToGithubButton = styled.a`
-  width: 150px;
+  width: 180px;
   justify-content: center;
   border-radius: 5px;
   display: flex;
@@ -173,16 +219,18 @@ const ConnectToGithubButton = styled.a`
   padding: 10px;
   overflow: hidden;
   white-space: nowrap;
+  margin-top: 25px;
+  margin-bottom: 25px;
   text-overflow: ellipsis;
   box-shadow: 0 5px 8px 0px #00000010;
   cursor: ${(props: { disabled?: boolean }) =>
     props.disabled ? "not-allowed" : "pointer"};
 
   background: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+    props.disabled ? "#aaaabbee" : "#2E3338"};
   :hover {
     background: ${(props: { disabled?: boolean }) =>
-      props.disabled ? "" : "#505edddd"};
+      props.disabled ? "" : "#353a3e"};
   }
 
   > i {

+ 0 - 2
dashboard/src/main/home/onboarding/steps/NewProject.tsx

@@ -187,8 +187,6 @@ const SlideWrapper = styled.div<{ delay?: string }>`
 `;
 
 const StyledNewProject = styled.div`
-  display: column;
-  align-item: center;
 `;
 
 const NewProjectSaveButton = styled(SaveButton)`

+ 3 - 1
dashboard/src/main/home/onboarding/types.ts

@@ -89,7 +89,7 @@ export type SkipProvisionConfig = {
 
 export type SkipRegistryConnection = SkipProvisionConfig;
 
-interface Onboarding {
+export interface Onboarding {
   current_step: string;
 
   project_id: number;
@@ -99,12 +99,14 @@ interface Onboarding {
 
   skip_registry_connection: boolean;
 
+  registry_connection_provider: string;
   registry_connection_credentials_id: number;
   registry_connection_settings_url: string;
   registry_connection_settings_name: string;
 
   skip_resource_provision: boolean;
 
+  resource_provision_provider: string;
   resource_provision_credentials_id: number;
   resource_provision_credentials_arn: string;
   resource_provision_credentials_region: string;

+ 16 - 15
dashboard/src/main/home/project-settings/BillingPage.tsx

@@ -5,7 +5,7 @@ import { Context } from "shared/Context";
 
 function BillingPage() {
   const [customerToken, setCustomerToken] = useState("");
-  const { currentProject, setCurrentError } = useContext(Context);
+  const { currentProject, setCurrentError, queryUsage } = useContext(Context);
 
   useEffect(() => {
     let isSubscripted = true;
@@ -22,6 +22,7 @@ function BillingPage() {
       });
     return () => {
       isSubscripted = false;
+      queryUsage();
     };
   }, [currentProject?.id]);
 
@@ -31,28 +32,28 @@ function BillingPage() {
         <PlanSelect
           theme={{
             base: {
-              customFont: 'Work Sans',
+              customFont: "Work Sans",
               fontFamily: '"Work Sans", sans-serif',
-              darkMode: 'on',
+              darkMode: "on",
               colors: {
-                primary: 'rgba(97, 111, 238, 0.8)',
-                secondary: 'rgb(103, 108, 124)',
-                danger: 'rgb(227, 54, 109)',
-                success: 'rgb(56, 168, 138)',
+                primary: "rgba(97, 111, 238, 0.8)",
+                secondary: "rgb(103, 108, 124)",
+                danger: "rgb(227, 54, 109)",
+                success: "rgb(56, 168, 138)",
               },
             },
             card: {
-              backgroundColor: 'rgb(38, 40, 47)',
-              boxShadow: 'rgb(0 0 0 / 33%) 0px 4px 15px 0px',
-              borderRadius: '8px',
-              border: '2px solid rgba(158, 180, 255, 0)',
+              backgroundColor: "rgb(38, 40, 47)",
+              boxShadow: "rgb(0 0 0 / 33%) 0px 4px 15px 0px",
+              borderRadius: "8px",
+              border: "2px solid rgba(158, 180, 255, 0)",
             },
             button: {
               base: {
-                boxShadow: 'rgb(0 0 0 / 19%) 0px 2px 5px 0px',
-                borderRadius: '5px',
-                fontSize: '14px',
-                fontWeight: '500',
+                boxShadow: "rgb(0 0 0 / 19%) 0px 2px 5px 0px",
+                borderRadius: "5px",
+                fontSize: "14px",
+                fontWeight: "500",
               },
             },
           }}

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

@@ -31,6 +31,7 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
     setCurrentError,
     user,
     edition,
+    usage,
   } = useContext(Context);
   const [isLoading, setIsLoading] = useState(true);
   const [invites, setInvites] = useState<Array<InviteType>>([]);
@@ -364,43 +365,56 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
     return mappedInviteList || [];
   }, [invites, currentProject?.id, window?.location?.host, isHTTPS, user?.id]);
 
-  const isEnterpriseEdition = () => {
-    return edition === "ee";
+  const hasSeats = () => {
+    // If usage limit is 0, the project has unlimited seats. Otherwise, check
+    // the usage limit against the current usage.
+    if (usage?.limit.users === 0) {
+      return true;
+    }
+
+    return usage?.current.users < usage?.limit.users;
   };
 
+  if (!usage) {
+    <Loading height={"30%"} />;
+  }
+
   return (
     <>
-      {isEnterpriseEdition() && (
-        <>
-          <Heading isAtTop={true}>Share Project</Heading>
-          <Helper>Generate a project invite for another user.</Helper>
-          <InputRowWrapper>
-            <InputRow
-              value={email}
-              type="text"
-              setValue={(newEmail: string) => setEmail(newEmail)}
-              width="100%"
-              placeholder="ex: mrp@getporter.dev"
-            />
-          </InputRowWrapper>
-          <Helper>Specify a role for this user.</Helper>
-          <RoleSelectorWrapper>
-            <RadioSelector
-              selected={role}
-              setSelected={setRole}
-              options={roleList}
-            />
-          </RoleSelectorWrapper>
-          <ButtonWrapper>
-            <InviteButton disabled={false} onClick={() => validateEmail()}>
-              Create Invite
-            </InviteButton>
-            {isInvalidEmail && (
-              <Invalid>Invalid email address. Please try again.</Invalid>
-            )}
-          </ButtonWrapper>
-        </>
-      )}
+      <>
+        <Heading isAtTop={true}>Share Project</Heading>
+        <Helper>Generate a project invite for another user.</Helper>
+        <InputRowWrapper>
+          <InputRow
+            value={email}
+            type="text"
+            setValue={(newEmail: string) => setEmail(newEmail)}
+            width="100%"
+            placeholder="ex: mrp@getporter.dev"
+          />
+        </InputRowWrapper>
+        <Helper>Specify a role for this user.</Helper>
+        <RoleSelectorWrapper>
+          <RadioSelector
+            selected={role}
+            setSelected={setRole}
+            options={roleList}
+          />
+        </RoleSelectorWrapper>
+        <ButtonWrapper>
+          <InviteButton disabled={!hasSeats()} onClick={() => validateEmail()}>
+            Create Invite
+          </InviteButton>
+          {isInvalidEmail && (
+            <Invalid>Invalid email address. Please try again.</Invalid>
+          )}
+          {!hasSeats() && (
+            <Invalid>
+              You need to upgrade your plan to invite more users to the project
+            </Invalid>
+          )}
+        </ButtonWrapper>
+      </>
 
       <Heading>Invites & Collaborators</Heading>
       <Helper>Manage pending invites and view collaborators.</Helper>

+ 30 - 5
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -37,6 +37,26 @@ class ProjectSettings extends Component<PropsType, StateType> {
         this.setState({ currentTab: "manage-access" });
       }
     }
+    if (
+      this.context?.hasBillingEnabled &&
+      !this.state.tabOptions.find((t) => t.value === "billing")
+    ) {
+      const tabOptions = this.state.tabOptions;
+      tabOptions.splice(1, 0, { value: "billing", label: "Billing" });
+      this.setState({ tabOptions });
+      return;
+    }
+
+    if (
+      !this.context?.hasBillingEnabled &&
+      this.state.tabOptions.find((t) => t.value === "billing")
+    ) {
+      const tabOptions = this.state.tabOptions;
+      const billingIndex = this.state.tabOptions.findIndex(
+        (t) => t.value === "billing"
+      );
+      tabOptions.splice(billingIndex, 1);
+    }
   }
 
   componentDidMount() {
@@ -46,10 +66,12 @@ class ProjectSettings extends Component<PropsType, StateType> {
     tabOptions.push({ value: "manage-access", label: "Manage Access" });
 
     if (this.props.isAuthorized("settings", "", ["get", "delete"])) {
-      tabOptions.push({
-        value: "billing",
-        label: "Billing",
-      });
+      if (this.context?.hasBillingEnabled) {
+        tabOptions.push({
+          value: "billing",
+          label: "Billing",
+        });
+      }
       tabOptions.push({
         value: "additional-settings",
         label: "Additional Settings",
@@ -69,7 +91,10 @@ class ProjectSettings extends Component<PropsType, StateType> {
       return <InvitePage />;
     }
 
-    if (this.state.currentTab === "billing") {
+    if (
+      this.state.currentTab === "billing" &&
+      this.context?.hasBillingEnabled
+    ) {
       return <BillingPage />;
     }
 

+ 27 - 8
dashboard/src/main/home/provisioner/ProvisionerSettings.tsx

@@ -1,4 +1,10 @@
-import React, { Component, useContext, useEffect, useState } from "react";
+import React, {
+  Component,
+  useContext,
+  useEffect,
+  useMemo,
+  useState,
+} from "react";
 import styled from "styled-components";
 
 import { Context } from "shared/Context";
@@ -32,7 +38,7 @@ const ProvisionerSettings: React.FC<Props> = ({
   const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
   const [highlightCosts, setHighlightCosts] = useState(true);
 
-  const { setCurrentError } = useContext(Context);
+  const { setCurrentError, usage, hasBillingEnabled } = useContext(Context);
   const location = useLocation();
   const history = useHistory();
 
@@ -42,6 +48,13 @@ const ProvisionerSettings: React.FC<Props> = ({
     }
   }, [provisioner]);
 
+  const isUsageExceeded = useMemo(() => {
+    if (!hasBillingEnabled) {
+      return false;
+    }
+    return usage.current.clusters >= usage.limit.clusters;
+  }, [usage]);
+
   const handleSelectProvider = (newSelectedProvider: string) => {
     if (!isInNewProject) {
       setSelectedProvider(newSelectedProvider);
@@ -228,9 +241,12 @@ const ProvisionerSettings: React.FC<Props> = ({
             return (
               <Block
                 key={i}
+                disabled={isUsageExceeded}
                 onClick={() => {
-                  handleSelectProvider(provider);
-                  setHighlightCosts(false);
+                  if (!isUsageExceeded) {
+                    handleSelectProvider(provider);
+                    setHighlightCosts(false);
+                  }
                 }}
               >
                 <Icon src={providerInfo.icon} />
@@ -238,8 +254,10 @@ const ProvisionerSettings: React.FC<Props> = ({
                 <CostSection
                   onClick={(e) => {
                     e.stopPropagation();
-                    handleSelectProvider(provider);
-                    setHighlightCosts(true);
+                    if (!isUsageExceeded) {
+                      handleSelectProvider(provider);
+                      setHighlightCosts(true);
+                    }
                   }}
                 >
                   {/*
@@ -341,10 +359,10 @@ const Block = styled.div<{ disabled?: boolean }>`
   font-weight: 500;
   padding: 3px 0px 5px;
   flex-direction: column;
-  align-item: center;
+  align-items: center;
   justify-content: space-between;
   height: 170px;
-  cursor: ${(props) => (props.disabled ? "" : "pointer")};
+  cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
   color: #ffffff;
   position: relative;
   background: #26282f;
@@ -352,6 +370,7 @@ const Block = styled.div<{ disabled?: boolean }>`
   :hover {
     background: ${(props) => (props.disabled ? "" : "#ffffff11")};
   }
+  filter: ${({ disabled }) => (disabled ? "grayscale(1)" : "")};
 
   animation: fadeIn 0.3s 0s;
   @keyframes fadeIn {

+ 31 - 1
dashboard/src/shared/Context.tsx

@@ -5,9 +5,11 @@ import {
   ClusterType,
   ContextProps,
   ProjectType,
+  UsageData,
 } from "shared/types";
 
 import { pushQueryParams } from "shared/routing";
+import api from "./api";
 
 const Context = React.createContext<Partial<ContextProps>>(null);
 
@@ -51,6 +53,11 @@ export interface GlobalContextType {
   clearContext: () => void;
   edition: "ee" | "ce";
   setEdition: (appVersion: string) => void;
+  hasBillingEnabled: boolean;
+  setHasBillingEnabled: (isBillingEnabled: boolean) => void;
+  usage: UsageData;
+  setUsage: (usage: UsageData) => void;
+  queryUsage: (retry?: number) => Promise<void>;
 }
 
 /**
@@ -145,10 +152,33 @@ class ContextProvider extends Component<PropsType, StateType> {
         this.setState({ edition });
       }
     },
+    hasBillingEnabled: null,
+    setHasBillingEnabled: (isBillingEnabled: boolean) => {
+      this.setState({ hasBillingEnabled: isBillingEnabled });
+    },
+    usage: null,
+    setUsage: (usage: UsageData) => {
+      this.setState({ usage });
+    },
+    queryUsage: async (retry: number = 0) => {
+      api
+        .getUsage("<token>", {}, { project_id: this.state?.currentProject?.id })
+        .then((res) => {
+          if (JSON.stringify(res.data) !== JSON.stringify(this.state.usage)) {
+            this.state.setUsage(res.data);
+          } else {
+            if (retry < 10) {
+              setTimeout(() => {
+                this.state.queryUsage(retry + 1);
+              }, 1000);
+            }
+          }
+        });
+    },
   };
 
   render() {
-    return <Provider value={this.state}>{this.props.children}</Provider>;
+    return <Provider value={{ ...this.state }}>{this.props.children}</Provider>;
   }
 }
 

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

@@ -1066,6 +1066,11 @@ const getCustomerToken = baseApi<{}, { project_id: number }>(
   ({ project_id }) => `/api/projects/${project_id}/billing/token`
 );
 
+const getHasBilling = baseApi<{}, { project_id: number }>(
+  "GET",
+  ({ project_id }) => `/api/projects/${project_id}/billing`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -1174,4 +1179,5 @@ export default {
   createWebhookToken,
   getUsage,
   getCustomerToken,
+  getHasBilling,
 };

+ 1 - 0
dashboard/src/shared/error_handling/window_error_handling.ts

@@ -1,4 +1,5 @@
 import { stackFramesToString } from "./stack_trace_utils";
+import StackTrace from "stacktrace-js";
 import * as Sentry from "@sentry/react";
 
 export function EnableErrorHandling() {

+ 15 - 14
dashboard/src/shared/types.tsx

@@ -298,6 +298,11 @@ export interface ContextProps {
   clearContext: () => void;
   edition: "ee" | "ce";
   setEdition: (appVersion: string) => void;
+  hasBillingEnabled: boolean;
+  setHasBillingEnabled: (isBillingEnabled: boolean) => void;
+  usage: UsageData;
+  setUsage: (usage: UsageData) => void;
+  queryUsage: () => Promise<void>;
 }
 
 export enum JobStatusType {
@@ -311,20 +316,16 @@ export interface JobStatusWithTimeType {
   start_time: string;
 }
 
+export interface Usage {
+  resource_cpu: number;
+  resource_memory: number;
+  clusters: number;
+  users: number;
+}
+
 export interface UsageData {
-  current: {
-    [key: string]: number;
-    resource_cpu: number;
-    resource_memory: number;
-    clusters: number;
-    users: number;
-  };
-  limit: {
-    [key: string]: number;
-    resource_cpu: number;
-    resource_memory: number;
-    clusters: number;
-    users: number;
-  };
+  current: Usage & { [key: string]: number };
+  limit: Usage & { [key: string]: number };
   exceeds: boolean;
+  exceeded_since?: string;
 }

+ 95 - 4
ee/billing/ironplans.go

@@ -31,15 +31,34 @@ type Client struct {
 	repo      repository.EERepository
 
 	httpClient *http.Client
+
+	defaultPlan *Plan
 }
 
 // NewClient creates a new billing API client
-func NewClient(serverURL, apiKey string, repo repository.EERepository) *Client {
+func NewClient(serverURL, apiKey string, repo repository.EERepository) (*Client, error) {
 	httpClient := &http.Client{
 		Timeout: time.Minute,
 	}
 
-	return &Client{apiKey, serverURL, repo, httpClient}
+	client := &Client{apiKey, serverURL, repo, httpClient, nil}
+
+	// get the default plans from the IronPlans API server
+	listResp := &ListPlansResponse{}
+	err := client.getRequest("/plans/v1", listResp)
+
+	if err != nil {
+		return nil, err
+	}
+
+	for _, plan := range listResp.Results {
+		if plan.Name == "Free" {
+			copyPlan := plan
+			client.defaultPlan = &copyPlan
+		}
+	}
+
+	return client, nil
 }
 
 func (c *Client) CreateTeam(proj *cemodels.Project) (string, error) {
@@ -52,6 +71,20 @@ func (c *Client) CreateTeam(proj *cemodels.Project) (string, error) {
 		return "", err
 	}
 
+	// put the user on the free plan, as the default behavior, if there is a default plan
+	if c.defaultPlan != nil {
+		err := c.postRequest("/subscriptions/v1", &CreateSubscriptionRequest{
+			PlanID:     c.defaultPlan.ID,
+			NextPlanID: c.defaultPlan.ID,
+			TeamID:     resp.ID,
+			IsPaused:   false,
+		}, nil)
+
+		if err != nil {
+			return "", fmt.Errorf("subscription creation failed: %s", err)
+		}
+	}
+
 	_, err = c.repo.ProjectBilling().CreateProjectBilling(&models.ProjectBilling{
 		ProjectID:     proj.ID,
 		BillingTeamID: resp.ID,
@@ -64,6 +97,16 @@ func (c *Client) CreateTeam(proj *cemodels.Project) (string, error) {
 	return resp.ID, err
 }
 
+func (c *Client) DeleteTeam(proj *cemodels.Project) error {
+	projBilling, err := c.repo.ProjectBilling().ReadProjectBillingByProjectID(proj.ID)
+
+	if err != nil {
+		return err
+	}
+
+	return c.deleteRequest(fmt.Sprintf("/teams/v1/%s", projBilling.BillingTeamID), nil, nil)
+}
+
 func (c *Client) GetTeamID(proj *cemodels.Project) (teamID string, err error) {
 	projBilling, err := c.repo.ProjectBilling().ReadProjectBillingByProjectID(proj.ID)
 
@@ -245,6 +288,54 @@ func (c *Client) deleteRequest(path string, data interface{}, dst interface{}) e
 	return c.writeRequest("DELETE", path, data, dst)
 }
 
+func (c *Client) getRequest(path string, dst interface{}) error {
+	reqURL, err := url.Parse(c.serverURL)
+
+	if err != nil {
+		return nil
+	}
+
+	reqURL.Path = path
+
+	req, err := http.NewRequest(
+		"GET",
+		reqURL.String(),
+		nil,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	req.Header.Set("Content-Type", "application/json; charset=utf-8")
+	req.Header.Set("Accept", "application/json; charset=utf-8")
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey))
+
+	res, err := c.httpClient.Do(req)
+
+	if err != nil {
+		return err
+	}
+
+	defer res.Body.Close()
+
+	if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusBadRequest {
+		resBytes, err := ioutil.ReadAll(res.Body)
+
+		if err != nil {
+			return fmt.Errorf("request failed with status code %d, but could not read body (%s)\n", res.StatusCode, err.Error())
+		}
+
+		return fmt.Errorf("request failed with status code %d: %s\n", res.StatusCode, string(resBytes))
+	}
+
+	if dst != nil {
+		return json.NewDecoder(res.Body).Decode(dst)
+	}
+
+	return nil
+}
+
 func (c *Client) writeRequest(method, path string, data interface{}, dst interface{}) error {
 	reqURL, err := url.Parse(c.serverURL)
 
@@ -337,8 +428,8 @@ func (c *Client) ParseProjectUsageFromWebhook(payload []byte) (*cemodels.Project
 
 	for _, feature := range subscription.Plan.Features {
 		// look for slug of "cpus" and "memory"
-		maxLimit := uint(feature.MaxLimit)
-		switch feature.Slug {
+		maxLimit := uint(feature.FeatureSpec.MaxLimit)
+		switch feature.Feature.Slug {
 		case FeatureSlugCPU:
 			usage.ResourceCPU = maxLimit
 		case FeatureSlugMemory:

+ 16 - 2
ee/billing/types.go

@@ -38,12 +38,19 @@ type Plan struct {
 	Features   []PlanFeature `json:"features"`
 }
 
+type ListPlansResponse struct {
+	Results []Plan `json:"results"`
+}
+
 type PlanFeature struct {
 	ID          string      `json:"id"`
 	IsActive    bool        `json:"is_active"`
+	Feature     Feature     `json:"feature"`
 	FeatureSpec FeatureSpec `json:"spec"`
-	Slug        string      `json:"slug"`
-	MaxLimit    int64       `json:"max_limit"`
+}
+
+type Feature struct {
+	Slug string `json:"slug"`
 }
 
 type FeatureSpec struct {
@@ -82,3 +89,10 @@ type SubscriptionWebhookRequest struct {
 	TeamID    string `json:"team_id"`
 	Plan      Plan   `json:"plan"`
 }
+
+type CreateSubscriptionRequest struct {
+	PlanID     string `json:"plan_id"`
+	TeamID     string `json:"team_id"`
+	IsPaused   bool   `json:"is_paused"`
+	NextPlanID string `json:"next_plan_id"`
+}

+ 3 - 2
ee/usage/limit.go

@@ -12,11 +12,12 @@ import (
 )
 
 func GetLimit(repo repository.Repository, proj *models.Project) (limit *types.ProjectUsage, err error) {
-	// query for the project limit; if not found, default to basic
+	// query for the project limit; if not found, no limits
 	limitModel, err := repo.ProjectUsage().ReadProjectUsage(proj.ID)
 
 	if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
-		copyBasic := types.BasicPlan
+		// place existing users without usage on enterprise plan
+		copyBasic := types.EnterprisePlan
 		limit = &copyBasic
 	} else if err != nil {
 		return nil, err

+ 7 - 0
internal/billing/billing.go

@@ -13,6 +13,9 @@ type BillingManager interface {
 	// per same team)
 	CreateTeam(proj *models.Project) (teamID string, err error)
 
+	// DeleteTeam deletes a billing team.
+	DeleteTeam(proj *models.Project) (err error)
+
 	// GetTeamID gets the billing team id for a project
 	GetTeamID(proj *models.Project) (teamID string, err error)
 
@@ -46,6 +49,10 @@ func (n *NoopBillingManager) CreateTeam(proj *models.Project) (teamID string, er
 	return fmt.Sprintf("%d", proj.ID), nil
 }
 
+func (n *NoopBillingManager) DeleteTeam(proj *models.Project) (err error) {
+	return nil
+}
+
 func (n *NoopBillingManager) GetTeamID(proj *models.Project) (teamID string, err error) {
 	return fmt.Sprintf("%d", proj.ID), nil
 }

+ 33 - 0
internal/models/onboarding.go

@@ -0,0 +1,33 @@
+package models
+
+import (
+	"gorm.io/gorm"
+
+	"github.com/porter-dev/porter/api/types"
+)
+
+type Onboarding struct {
+	gorm.Model
+
+	ProjectID              uint
+	CurrentStep            types.StepEnum
+	ConnectedSource        types.ConnectedSourceType
+	SkipRegistryConnection bool
+	SkipResourceProvision  bool
+	RegistryConnectionID   uint
+	RegistryInfraID        uint
+	ClusterInfraID         uint
+}
+
+// ToOnboardingType generates an external types.OnboardingData to be shared over REST
+func (o *Onboarding) ToOnboardingType() *types.OnboardingData {
+	return &types.OnboardingData{
+		CurrentStep:            o.CurrentStep,
+		ConnectedSource:        o.ConnectedSource,
+		SkipRegistryConnection: o.SkipRegistryConnection,
+		SkipResourceProvision:  o.SkipResourceProvision,
+		RegistryConnectionID:   o.RegistryConnectionID,
+		RegistryInfraID:        o.RegistryInfraID,
+		ClusterInfraID:         o.ClusterInfraID,
+	}
+}

+ 2 - 2
internal/models/usage.go

@@ -58,7 +58,7 @@ type ProjectUsageCache struct {
 	ExceededSince *time.Time
 }
 
-func (p *ProjectUsageCache) Is24HrOld() bool {
+func (p *ProjectUsageCache) Is1HrOld() bool {
 	timeSince := time.Now().Sub(p.UpdatedAt)
-	return timeSince > 24*time.Hour
+	return timeSince > 1*time.Hour
 }

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

@@ -64,6 +64,7 @@ func setupTestEnv(tester *tester, t *testing.T) {
 		&models.Infra{},
 		&models.GitActionConfig{},
 		&models.Invite{},
+		&models.Onboarding{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

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

@@ -31,6 +31,7 @@ func AutoMigrate(db *gorm.DB) error {
 		&models.SubEvent{},
 		&models.ProjectUsage{},
 		&models.ProjectUsageCache{},
+		&models.Onboarding{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

+ 52 - 0
internal/repository/gorm/onboarding.go

@@ -0,0 +1,52 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// ProjectOnboardingRepository implements repository.ProjectOnboardingRepository
+type ProjectOnboardingRepository struct {
+	db *gorm.DB
+}
+
+// NewProjectOnboardingRepository will return errors if canQuery is false
+func NewProjectOnboardingRepository(db *gorm.DB) repository.ProjectOnboardingRepository {
+	return &ProjectOnboardingRepository{db}
+}
+
+// CreateProjectOnboarding creates a new project onboarding limit
+func (repo *ProjectOnboardingRepository) CreateProjectOnboarding(
+	onboarding *models.Onboarding,
+) (*models.Onboarding, error) {
+	if err := repo.db.Create(onboarding).Error; err != nil {
+		return nil, err
+	}
+
+	return onboarding, nil
+}
+
+// ReadProjectOnboarding finds the project onboarding matching a project ID
+func (repo *ProjectOnboardingRepository) ReadProjectOnboarding(
+	projID uint,
+) (*models.Onboarding, error) {
+	res := &models.Onboarding{}
+
+	if err := repo.db.Where("project_id = ?", projID).First(res).Error; err != nil {
+		return nil, err
+	}
+
+	return res, nil
+}
+
+// UpdateProjectOnboarding modifies an existing ProjectOnboarding in the database
+func (repo *ProjectOnboardingRepository) UpdateProjectOnboarding(
+	onboarding *models.Onboarding,
+) (*models.Onboarding, error) {
+	if err := repo.db.Save(onboarding).Error; err != nil {
+		return nil, err
+	}
+
+	return onboarding, nil
+}

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

@@ -28,7 +28,7 @@ func (repo *ReleaseRepository) CreateRelease(release *models.Release) (*models.R
 // ReadRelease finds a single release based on their unique name and namespace pair.
 func (repo *ReleaseRepository) ReadRelease(clusterID uint, name, namespace string) (*models.Release, error) {
 	release := &models.Release{}
-	if err := repo.db.Preload("GitActionConfig").Where("cluster_id = ?", clusterID).Where("name = ?", name).Where("namespace = ?", namespace).First(&release).Error; err != nil {
+	if err := repo.db.Preload("GitActionConfig").Order("id desc").Where("cluster_id = ? AND name = ? AND namespace = ?", clusterID, name, namespace).First(&release).Error; err != nil {
 		return nil, err
 	}
 	return release, nil

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

@@ -32,6 +32,7 @@ type GormRepository struct {
 	notificationConfig        repository.NotificationConfigRepository
 	event                     repository.EventRepository
 	projectUsage              repository.ProjectUsageRepository
+	onboarding                repository.ProjectOnboardingRepository
 }
 
 func (t *GormRepository) User() repository.UserRepository {
@@ -138,6 +139,10 @@ func (t *GormRepository) ProjectUsage() repository.ProjectUsageRepository {
 	return t.projectUsage
 }
 
+func (t *GormRepository) Onboarding() repository.ProjectOnboardingRepository {
+	return t.onboarding
+}
+
 // NewRepository returns a Repository which persists users in memory
 // and accepts a parameter that can trigger read/write errors
 func NewRepository(db *gorm.DB, key *[32]byte) repository.Repository {
@@ -168,5 +173,6 @@ func NewRepository(db *gorm.DB, key *[32]byte) repository.Repository {
 		notificationConfig:        NewNotificationConfigRepository(db),
 		event:                     NewEventRepository(db),
 		projectUsage:              NewProjectUsageRepository(db),
+		onboarding:                NewProjectOnboardingRepository(db),
 	}
 }

+ 10 - 0
internal/repository/onboarding.go

@@ -0,0 +1,10 @@
+package repository
+
+import "github.com/porter-dev/porter/internal/models"
+
+// ProjectOnboardingRepository represents the set of queries on the Onboarding model
+type ProjectOnboardingRepository interface {
+	CreateProjectOnboarding(onboarding *models.Onboarding) (*models.Onboarding, error)
+	ReadProjectOnboarding(projID uint) (*models.Onboarding, error)
+	UpdateProjectOnboarding(cache *models.Onboarding) (*models.Onboarding, error)
+}

+ 1 - 0
internal/repository/repository.go

@@ -27,4 +27,5 @@ type Repository interface {
 	NotificationConfig() NotificationConfigRepository
 	Event() EventRepository
 	ProjectUsage() ProjectUsageRepository
+	Onboarding() ProjectOnboardingRepository
 }

+ 75 - 0
internal/repository/test/onboarding.go

@@ -0,0 +1,75 @@
+package test
+
+import (
+	"errors"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// ProjectOnboardingRepository implements repository.ProjectOnboardingRepository
+type ProjectOnboardingRepository struct {
+	canQuery    bool
+	onboardings []*models.Onboarding
+}
+
+// NewProjectOnboardingRepository will return errors if canQuery is false
+func NewProjectOnboardingRepository(canQuery bool) repository.ProjectOnboardingRepository {
+	return &ProjectOnboardingRepository{
+		canQuery,
+		[]*models.Onboarding{},
+	}
+}
+
+// CreateProjectOnboarding creates a new project onboarding limit
+func (repo *ProjectOnboardingRepository) CreateProjectOnboarding(
+	onboarding *models.Onboarding,
+) (*models.Onboarding, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if onboarding == nil {
+		return nil, nil
+	}
+
+	repo.onboardings = append(repo.onboardings, onboarding)
+
+	return onboarding, nil
+}
+
+// CreateProjectOnboarding reads a project onboarding by project id
+func (repo *ProjectOnboardingRepository) ReadProjectOnboarding(
+	projID uint,
+) (*models.Onboarding, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	for _, pu := range repo.onboardings {
+		if pu != nil && pu.ProjectID == projID {
+			return pu, nil
+		}
+	}
+
+	return nil, gorm.ErrRecordNotFound
+}
+
+// UpdateProjectOnboarding modifies an existing ProjectOnboarding in the database
+func (repo *ProjectOnboardingRepository) UpdateProjectOnboarding(
+	onboarding *models.Onboarding,
+) (*models.Onboarding, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if int(onboarding.ID-1) >= len(repo.onboardings) || repo.onboardings[onboarding.ID-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(onboarding.ID - 1)
+	repo.onboardings[index] = onboarding
+
+	return onboarding, nil
+}

+ 7 - 0
internal/repository/test/repository.go

@@ -31,6 +31,7 @@ type TestRepository struct {
 	notificationConfig        repository.NotificationConfigRepository
 	event                     repository.EventRepository
 	projectUsage              repository.ProjectUsageRepository
+	onboarding                repository.ProjectOnboardingRepository
 }
 
 func (t *TestRepository) User() repository.UserRepository {
@@ -137,6 +138,10 @@ func (t *TestRepository) ProjectUsage() repository.ProjectUsageRepository {
 	return t.projectUsage
 }
 
+func (t *TestRepository) Onboarding() repository.ProjectOnboardingRepository {
+	return t.onboarding
+}
+
 // NewRepository returns a Repository which persists users in memory
 // and accepts a parameter that can trigger read/write errors
 func NewRepository(canQuery bool, failingMethods ...string) repository.Repository {
@@ -166,5 +171,7 @@ func NewRepository(canQuery bool, failingMethods ...string) repository.Repositor
 		slackIntegration:          NewSlackIntegrationRepository(canQuery),
 		notificationConfig:        NewNotificationConfigRepository(canQuery),
 		event:                     NewEventRepository(canQuery),
+		projectUsage:              NewProjectUsageRepository(canQuery),
+		onboarding:                NewProjectOnboardingRepository(canQuery),
 	}
 }

+ 29 - 9
internal/usage/usage.go

@@ -14,9 +14,10 @@ import (
 )
 
 type GetUsageOpts struct {
-	Repo    repository.Repository
-	DOConf  *oauth2.Config
-	Project *models.Project
+	Repo             repository.Repository
+	DOConf           *oauth2.Config
+	Project          *models.Project
+	WhitelistedUsers map[uint]uint
 }
 
 // GetUsage gets a project's current usage and usage limit
@@ -45,6 +46,14 @@ func GetUsage(opts *GetUsageOpts) (
 		return nil, nil, nil, err
 	}
 
+	countedRoles := make([]models.Role, 0)
+
+	for _, role := range roles {
+		if _, exists := opts.WhitelistedUsers[role.UserID]; !exists {
+			countedRoles = append(countedRoles, role)
+		}
+	}
+
 	usageCache, err := opts.Repo.ProjectUsage().ReadProjectUsageCache(opts.Project.ID)
 	isCacheFound := true
 
@@ -52,9 +61,9 @@ func GetUsage(opts *GetUsageOpts) (
 		return nil, nil, nil, err
 	}
 
-	// if the usage cache is 24 hours old, was not found, or usage is over limit,
+	// if the usage cache is 1 hour old, was not found, or usage is over limit,
 	// re-query for the usage
-	if !isCacheFound || usageCache.Is24HrOld() || usageCache.ResourceMemory > limit.ResourceMemory || usageCache.ResourceCPU > limit.ResourceCPU {
+	if !isCacheFound || usageCache.Is1HrOld() || usageCache.ResourceMemory > limit.ResourceMemory || usageCache.ResourceCPU > limit.ResourceCPU {
 		cpu, memory, err := getResourceUsage(opts, clusters)
 
 		if err != nil {
@@ -72,7 +81,7 @@ func GetUsage(opts *GetUsageOpts) (
 			usageCache.ResourceMemory = memory
 		}
 
-		isExceeded := usageCache.ResourceCPU > limit.ResourceCPU || usageCache.ResourceMemory > limit.ResourceMemory
+		isExceeded := isUsageExceeded(usageCache, limit, uint(len(countedRoles)), uint(len(clusters)))
 
 		if !usageCache.Exceeded && isExceeded {
 			// update the usage cache with a time exceeded
@@ -89,14 +98,27 @@ func GetUsage(opts *GetUsageOpts) (
 		}
 	}
 
+	// we check whether it's currently exceeded based on the cache every time, since
+	// it's an inexpensive operation and involves no further DB lookups
+	usageCache.Exceeded = isUsageExceeded(usageCache, limit, uint(len(countedRoles)), uint(len(clusters)))
+
 	return &types.ProjectUsage{
 		ResourceCPU:    usageCache.ResourceCPU,
 		ResourceMemory: usageCache.ResourceMemory,
 		Clusters:       uint(len(clusters)),
-		Users:          uint(len(roles)),
+		Users:          uint(len(countedRoles)),
 	}, limit, usageCache, nil
 }
 
+func isUsageExceeded(usageCache *models.ProjectUsageCache, limit *types.ProjectUsage, numUsers, numClusters uint) bool {
+	isCPUExceeded := limit.ResourceCPU != 0 && usageCache.ResourceCPU > limit.ResourceCPU
+	isMemExceeded := limit.ResourceMemory != 0 && usageCache.ResourceMemory > limit.ResourceMemory
+	isUsersExceeded := limit.Users != 0 && numUsers > limit.Users
+	isClustersExceeded := limit.Clusters != 0 && numClusters > limit.Clusters
+
+	return isCPUExceeded || isMemExceeded || isUsersExceeded || isClustersExceeded
+}
+
 // gets the total resource usage across all nodes in all clusters
 func getResourceUsage(opts *GetUsageOpts, clusters []*models.Cluster) (uint, uint, error) {
 	var totCPU, totMem uint = 0, 0
@@ -112,14 +134,12 @@ func getResourceUsage(opts *GetUsageOpts, clusters []*models.Cluster) (uint, uin
 
 		if err != nil {
 			continue
-			// return 0, 0, fmt.Errorf("failed to get agent: %s", err.Error())
 		}
 
 		totAlloc, err := nodes.GetAllocatableResources(agent.Clientset)
 
 		if err != nil {
 			continue
-			// return 0, 0, fmt.Errorf("failed to get alloc: %s", err.Error())
 		}
 
 		totCPU += totAlloc.CPU

+ 86 - 51
services/usage/usage.go

@@ -1,6 +1,7 @@
 package usage
 
 import (
+	"sync"
 	"time"
 
 	"github.com/porter-dev/porter/api/server/shared/config/env"
@@ -17,17 +18,19 @@ import (
 )
 
 type UsageTracker struct {
-	db     *gorm.DB
-	repo   repository.Repository
-	doConf *oauth2.Config
+	db               *gorm.DB
+	repo             repository.Repository
+	doConf           *oauth2.Config
+	whitelistedUsers map[uint]uint
 }
 
 type UsageTrackerOpts struct {
-	DBConf         *env.DBConf
-	DOClientID     string
-	DOClientSecret string
-	DOScopes       []string
-	ServerURL      string
+	DBConf           *env.DBConf
+	DOClientID       string
+	DOClientSecret   string
+	DOScopes         []string
+	ServerURL        string
+	WhitelistedUsers map[uint]uint
 }
 
 const stepSize = 100
@@ -54,7 +57,7 @@ func NewUsageTracker(opts *UsageTrackerOpts) (*UsageTracker, error) {
 		BaseURL:      opts.ServerURL,
 	})
 
-	return &UsageTracker{db, repo, doConf}, nil
+	return &UsageTracker{db, repo, doConf, opts.WhitelistedUsers}, nil
 }
 
 type UsageTrackerResponse struct {
@@ -62,9 +65,13 @@ type UsageTrackerResponse struct {
 	CPUUsage      uint
 	MemoryLimit   uint
 	MemoryUsage   uint
+	UserLimit     uint
+	UserUsage     uint
+	ClusterLimit  uint
+	ClusterUsage  uint
 	Exceeded      bool
-	ExceededSince *time.Time
-	Project       *models.Project
+	ExceededSince time.Time
+	Project       models.Project
 	AdminEmails   []string
 }
 
@@ -78,58 +85,86 @@ func (u *UsageTracker) GetProjectUsage() (map[uint]*UsageTrackerResponse, error)
 		return nil, err
 	}
 
-	// iterate (count / stepSize) + 1 times using Limit and Offset
-	for i := 0; i < (int(count)/stepSize)+1; i++ {
-		projects := []*models.Project{}
+	var mu sync.Mutex
+	var wg sync.WaitGroup
 
-		if err := u.db.Order("id asc").Offset(i * stepSize).Limit(stepSize).Find(&projects).Error; err != nil {
-			return nil, err
-		}
+	worker := func(project *models.Project) error {
+		defer wg.Done()
 
-		// go through each project
-		for _, project := range projects {
-			_, limit, cache, err := usage.GetUsage(&usage.GetUsageOpts{
-				Repo:    u.repo,
-				DOConf:  u.doConf,
-				Project: project,
-			})
-
-			if err != nil {
-				continue
-			}
+		current, limit, cache, err := usage.GetUsage(&usage.GetUsageOpts{
+			Repo:             u.repo,
+			DOConf:           u.doConf,
+			Project:          project,
+			WhitelistedUsers: u.whitelistedUsers,
+		})
 
-			// get the admin emails for the project
-			roles, err := u.repo.Project().ListProjectRoles(project.ID)
+		if err != nil {
+			return err
+		}
 
-			if err != nil {
-				continue
-			}
+		// get the admin emails for the project
+		roles, err := u.repo.Project().ListProjectRoles(project.ID)
 
-			adminEmails := make([]string, 0)
+		if err != nil {
+			return err
+		}
 
-			for _, role := range roles {
-				if role.Kind == types.RoleAdmin {
-					user, err := u.repo.User().ReadUser(role.UserID)
+		adminEmails := make([]string, 0)
 
-					if err != nil {
-						continue
-					}
+		for _, role := range roles {
+			if role.Kind == types.RoleAdmin {
+				user, err := u.repo.User().ReadUser(role.UserID)
 
-					adminEmails = append(adminEmails, user.Email)
+				if err != nil {
+					continue
 				}
-			}
 
-			res[project.ID] = &UsageTrackerResponse{
-				CPUUsage:      cache.ResourceCPU,
-				CPULimit:      limit.ResourceCPU,
-				MemoryUsage:   cache.ResourceMemory,
-				MemoryLimit:   limit.ResourceMemory,
-				Exceeded:      cache.Exceeded,
-				ExceededSince: cache.ExceededSince,
-				Project:       project,
-				AdminEmails:   adminEmails,
+				adminEmails = append(adminEmails, user.Email)
 			}
 		}
+
+		exceededSince := cache.ExceededSince
+
+		if exceededSince == nil {
+			now := time.Now()
+			exceededSince = &now
+		}
+
+		mu.Lock()
+		res[project.ID] = &UsageTrackerResponse{
+			CPUUsage:      cache.ResourceCPU,
+			CPULimit:      limit.ResourceCPU,
+			MemoryUsage:   cache.ResourceMemory,
+			MemoryLimit:   limit.ResourceMemory,
+			UserUsage:     current.Users,
+			UserLimit:     limit.Users,
+			ClusterUsage:  current.Clusters,
+			ClusterLimit:  limit.Clusters,
+			Exceeded:      cache.Exceeded,
+			ExceededSince: *exceededSince,
+			Project:       *project,
+			AdminEmails:   adminEmails,
+		}
+		mu.Unlock()
+
+		return nil
+	}
+
+	// iterate (count / stepSize) + 1 times using Limit and Offset
+	for i := 0; i < (int(count)/stepSize)+1; i++ {
+		projects := []*models.Project{}
+
+		if err := u.db.Order("id asc").Offset(i * stepSize).Limit(stepSize).Find(&projects).Error; err != nil {
+			return nil, err
+		}
+
+		// go through each project
+		for _, project := range projects {
+			wg.Add(1)
+			go worker(project)
+		}
+
+		wg.Wait()
 	}
 
 	return res, nil