dgtown 2 years ago
parent
commit
585376265a
45 changed files with 2336 additions and 515 deletions
  1. 38 44
      api/server/authn/handler.go
  2. 136 0
      api/server/handlers/invite/create.go
  3. 36 0
      api/server/handlers/invite/delete.go
  4. 0 69
      api/server/handlers/invite/invite_ce.go
  5. 0 45
      api/server/handlers/invite/invite_ee.go
  6. 43 0
      api/server/handlers/invite/list.go
  7. 43 0
      api/server/handlers/invite/update_role.go
  8. 85 36
      api/server/handlers/user/create.go
  9. 117 0
      api/server/handlers/user/invite_list.go
  10. 145 0
      api/server/handlers/user/invite_respond.go
  11. 141 0
      api/server/handlers/user/migrate.go
  12. 24 0
      api/server/router/base.go
  13. 0 26
      api/server/router/invite.go
  14. 50 1
      api/server/router/user.go
  15. 4 0
      api/server/shared/config/config.go
  16. 3 0
      api/server/shared/config/env/envconfs.go
  17. 9 0
      api/server/shared/config/loader/loader.go
  18. 8 6
      api/types/invite.go
  19. 9 7
      api/types/user.go
  20. 181 11
      dashboard/package-lock.json
  21. 3 0
      dashboard/package.json
  22. 204 0
      dashboard/src/components/UserInviteModal.tsx
  23. 19 0
      dashboard/src/lib/invites/types.ts
  24. 26 4
      dashboard/src/main/MainWrapper.tsx
  25. 48 182
      dashboard/src/main/auth/Login.tsx
  26. 184 0
      dashboard/src/main/auth/LoginWrapper.tsx
  27. 197 0
      dashboard/src/main/auth/OryLogin.tsx
  28. 39 3
      dashboard/src/main/home/Home.tsx
  29. 7 3
      dashboard/src/main/home/project-settings/InviteList.tsx
  30. 20 0
      dashboard/src/shared/api.tsx
  31. 144 42
      dashboard/src/shared/auth/AuthnContext.tsx
  32. 178 0
      dashboard/src/shared/auth/sdk.ts
  33. 14 1
      dashboard/webpack.config.js
  34. 11 9
      go.mod
  35. 29 17
      go.sum
  36. 20 1
      go.work.sum
  37. 20 8
      internal/models/invite.go
  38. 3 0
      internal/models/user.go
  39. 14 0
      internal/repository/gorm/invite.go
  40. 20 0
      internal/repository/gorm/user.go
  41. 1 0
      internal/repository/invite.go
  42. 8 0
      internal/repository/test/invite.go
  43. 10 0
      internal/repository/test/user.go
  44. 2 0
      internal/repository/user.go
  45. 43 0
      package-lock.json

+ 38 - 44
api/server/authn/handler.go

@@ -2,11 +2,11 @@ package authn
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"net/http"
 	"net/url"
 	"strings"
-	"time"
 
 	"github.com/gorilla/sessions"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
@@ -70,63 +70,57 @@ func (authn *AuthN) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	}
 
 	// if the bearer token is not found, look for a request cookie
-	session, err := authn.config.Store.Get(r, authn.config.ServerConf.CookieName)
-	if err != nil {
-		session.Values["authenticated"] = false
 
-		// we attempt to save the session, but do not catch the error since we send the
-		// forbidden error regardless
-		session.Save(r, w)
+	// first look for new ory cookie
+	// set the cookies on the ory client
+	var cookies string
+
+	// this example passes all request.Cookies
+	// to `ToSession` function
+	//
+	// However, you can pass only the value of
+	// ory_session_projectid cookie to the endpoint
+	cookies = r.Header.Get("Cookie")
 
+	fmt.Println("Cookies: ", cookies)
+
+	// check if we have a session
+	orySession, _, err := authn.config.Ory.FrontendAPI.ToSession(r.Context()).Cookie(cookies).Execute()
+	if err != nil {
 		authn.sendForbiddenError(err, w, r)
 		return
 	}
 
-	cancelTokens := func(lastIssueTime time.Time, cancelEmail string, authn *AuthN, session *sessions.Session) bool {
-		if email, ok := session.Values["email"]; ok {
-			if email.(string) == cancelEmail {
-				timeAsUTC := lastIssueTime.UTC()
-				sess, _ := authn.config.Repo.Session().SelectSession(&models.Session{Key: session.ID})
-				if sess.CreatedAt.UTC().Before(timeAsUTC) {
-					_, _ = authn.config.Repo.Session().DeleteSession(sess)
-					return true
-				}
-			}
-		}
-		return false
+	if orySession == nil {
+		err = errors.New("ory session is nil")
+		authn.sendForbiddenError(err, w, r)
+		return
 	}
-
-	est, err := time.LoadLocation("EST")
-	// if err == nil {
-	// 	authn.handleForbiddenForSession(w, r, fmt.Errorf("error, contact admin"), session)
-	// 	return
-	// }
-	// TODO: handle error from time.LoadLocation
-	if err == nil {
-		if cancelTokens(time.Date(2024, 0o1, 16, 18, 35, 0, 0, est), "support@porter.run", authn, session) {
-			authn.handleForbiddenForSession(w, r, fmt.Errorf("error, contact admin"), session)
-			return
-		}
-		if cancelTokens(time.Date(2024, 0o1, 16, 18, 35, 0, 0, est), "admin@porter.run", authn, session) {
-			authn.handleForbiddenForSession(w, r, fmt.Errorf("error, contact admin"), session)
-			return
-		}
+	if !*orySession.Active {
+		err = errors.New("ory session is not active")
+		authn.sendForbiddenError(err, w, r)
+		return
 	}
-
-	if auth, ok := session.Values["authenticated"].(bool); !auth || !ok {
-		authn.handleForbiddenForSession(w, r, fmt.Errorf("stored cookie was not authenticated"), session)
+	if orySession.Identity == nil {
+		err = errors.New("ory session identity is nil")
+		authn.sendForbiddenError(err, w, r)
 		return
 	}
 
-	// read the user id in the token
-	userID, ok := session.Values["user_id"].(uint)
-
-	if !ok {
-		authn.handleForbiddenForSession(w, r, fmt.Errorf("could not cast user_id to uint"), session)
+	fmt.Println("now in here")
+	// get user id from Ory
+	externalId := orySession.Identity.Id
+	user, err := authn.config.Repo.User().ReadUserByAuthProvider("ory", externalId)
+	if err != nil || user == nil {
+		err := fmt.Errorf("ory user not found in database", externalId)
+		authn.sendForbiddenError(err, w, r)
 		return
 	}
 
-	authn.nextWithUserID(w, r, userID)
+	fmt.Println("going next")
+
+	authn.nextWithUserID(w, r, user.ID)
+	return
 }
 
 func (authn *AuthN) handleForbiddenForSession(

+ 136 - 0
api/server/handlers/invite/create.go

@@ -0,0 +1,136 @@
+package invite
+
+import (
+	"net/http"
+	"time"
+
+	"github.com/porter-dev/porter/internal/telemetry"
+
+	"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"
+	"github.com/porter-dev/porter/internal/oauth"
+)
+
+type InviteCreateHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewInviteCreateHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) http.Handler {
+	return &InviteCreateHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *InviteCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-invite-create")
+	defer span.End()
+
+	user, _ := ctx.Value(types.UserScope).(*models.User)
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	request := &types.CreateInviteRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "message", Value: "failed to decode and validate request"})
+		return
+	}
+
+	//identities, _, err := c.Config().Ory.IdentityAPI.ListIdentities(context.WithValue(ctx, ory.ContextAccessToken, c.Config().OryApiKey)).CredentialsIdentifier(request.Email).Execute()
+	//if err != nil {
+	//	fmt.Println("dgt ory", err.Error())
+	//	return
+	//} else {
+	//	fmt.Println("dgt ory", identities)
+	//	return
+	//}
+	//
+	//basicIdentityBody := ory.CreateIdentityBody{
+	//	SchemaId: "preset://email",
+	//	Traits:   map[string]interface{}{"email": request.Email},
+	//}
+	//
+	//fmt.Println("dgt ory", c.Config().OryApiKey)
+	//
+	//identity, _, err := c.Config().Ory.IdentityAPI.CreateIdentity(context.WithValue(ctx, ory.ContextAccessToken, c.Config().OryApiKey)).CreateIdentityBody(basicIdentityBody).Execute()
+	//if err != nil {
+	//	err = telemetry.Error(ctx, span, err, "error creating identity")
+	//	c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	//	return
+	//}
+	//
+	//sevenDays := "7d"
+	//createRecoveryBody := ory.CreateRecoveryLinkForIdentityBody{
+	//	ExpiresIn:  &sevenDays,
+	//	IdentityId: identity.Id,
+	//}
+	//
+	//recoveryLink, _, err := c.Config().Ory.IdentityAPI.CreateRecoveryLinkForIdentity(context.WithValue(ctx, ory.ContextAccessToken, c.Config().OryApiKey)).CreateRecoveryLinkForIdentityBody(createRecoveryBody).Execute()
+	//if err != nil || recoveryLink == nil {
+	//	err = telemetry.Error(ctx, span, err, "error creating recovery link")
+	//	c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	//	return
+	//
+	//}
+
+	// create invite model
+	invite, err := CreateInviteWithProject(request, project.ID)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(telemetry.Error(ctx, span, err, "error creating invite with project")))
+		return
+	}
+
+	invite.InvitingUserID = user.ID
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "project-id", Value: invite.ProjectID},
+		telemetry.AttributeKV{Key: "user-id", Value: invite.UserID},
+		telemetry.AttributeKV{Key: "kind", Value: invite.Kind},
+	)
+
+	// write to database
+	invite, err = c.Repo().Invite().CreateInvite(invite)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(telemetry.Error(ctx, span, err, "error creating invite in repo")))
+		return
+	}
+
+	//if err := c.Config().UserNotifier.SendProjectInviteEmail(
+	//	&notifier.SendProjectInviteEmailOpts{
+	//		InviteeEmail:      request.Email,
+	//		URL:               recoveryLink.RecoveryLink,
+	//		Project:           project.Name,
+	//		ProjectOwnerEmail: user.Email,
+	//	},
+	//); err != nil {
+	//	c.HandleAPIError(w, r, apierrors.NewErrInternal(telemetry.Error(ctx, span, err, "error sending project invite email")))
+	//	return
+	//}
+
+	res := types.CreateInviteResponse{
+		Invite: invite.ToInviteType(),
+	}
+
+	c.WriteResult(w, r, res)
+}
+
+func CreateInviteWithProject(invite *types.CreateInviteRequest, projectID uint) (*models.Invite, error) {
+	// generate a token and an expiry time
+	expiry := time.Now().Add(7 * 24 * time.Hour)
+
+	return &models.Invite{
+		Token:     oauth.CreateRandomState(),
+		Expiry:    &expiry,
+		Email:     invite.Email,
+		Kind:      invite.Kind,
+		ProjectID: projectID,
+		Status:    models.InvitePending,
+	}, nil
+}

+ 36 - 0
api/server/handlers/invite/delete.go

@@ -0,0 +1,36 @@
+package invite
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"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"
+)
+
+type InviteDeleteHandler struct {
+	handlers.PorterHandler
+	authz.KubernetesAgentGetter
+}
+
+func NewInviteDeleteHandler(
+	config *config.Config,
+) http.Handler {
+	return &InviteDeleteHandler{
+		PorterHandler:         handlers.NewDefaultPorterHandler(config, nil, nil),
+		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *InviteDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	invite, _ := r.Context().Value(types.InviteScope).(*models.Invite)
+
+	if err := c.Repo().Invite().DeleteInvite(invite); err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	}
+
+	w.WriteHeader(http.StatusOK)
+}

+ 0 - 69
api/server/handlers/invite/invite_ce.go

@@ -1,69 +0,0 @@
-//go:build !ee
-// +build !ee
-
-package invite
-
-import (
-	"net/http"
-
-	"github.com/porter-dev/porter/api/server/authz"
-	"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"
-)
-
-type InviteUpdateRoleHandler struct {
-	handlers.PorterHandlerReader
-	handlers.Unavailable
-}
-
-func NewInviteUpdateRoleHandler(
-	config *config.Config,
-	decoderValidator shared.RequestDecoderValidator,
-) http.Handler {
-	return handlers.NewUnavailable(config, "invite_update_role")
-}
-
-type InviteAcceptHandler struct {
-	handlers.PorterHandler
-}
-
-func NewInviteAcceptHandler(
-	config *config.Config,
-) http.Handler {
-	return handlers.NewUnavailable(config, "invite_accept")
-}
-
-type InviteCreateHandler struct {
-	handlers.PorterHandlerReadWriter
-}
-
-func NewInviteCreateHandler(
-	config *config.Config,
-	decoderValidator shared.RequestDecoderValidator,
-	writer shared.ResultWriter,
-) http.Handler {
-	return handlers.NewUnavailable(config, "invite_create")
-}
-
-type InviteDeleteHandler struct {
-	handlers.PorterHandler
-	authz.KubernetesAgentGetter
-}
-
-func NewInviteDeleteHandler(
-	config *config.Config,
-) http.Handler {
-	return handlers.NewUnavailable(config, "invite_delete")
-}
-
-type InvitesListHandler struct {
-	handlers.PorterHandlerWriter
-}
-
-func NewInvitesListHandler(
-	config *config.Config,
-	writer shared.ResultWriter,
-) http.Handler {
-	return handlers.NewUnavailable(config, "invite_list")
-}

+ 0 - 45
api/server/handlers/invite/invite_ee.go

@@ -1,45 +0,0 @@
-//go:build ee
-// +build ee
-
-package invite
-
-import (
-	"net/http"
-
-	"github.com/porter-dev/porter/api/server/shared"
-	"github.com/porter-dev/porter/api/server/shared/config"
-
-	"github.com/porter-dev/porter/ee/api/server/handlers/invite"
-)
-
-var NewInviteUpdateRoleHandler func(
-	config *config.Config,
-	decoderValidator shared.RequestDecoderValidator,
-) http.Handler
-
-var NewInviteAcceptHandler func(
-	config *config.Config,
-) http.Handler
-
-var NewInviteCreateHandler func(
-	config *config.Config,
-	decoderValidator shared.RequestDecoderValidator,
-	writer shared.ResultWriter,
-) http.Handler
-
-var NewInviteDeleteHandler func(
-	config *config.Config,
-) http.Handler
-
-var NewInvitesListHandler func(
-	config *config.Config,
-	writer shared.ResultWriter,
-) http.Handler
-
-func init() {
-	NewInviteUpdateRoleHandler = invite.NewInviteUpdateRoleHandler
-	NewInviteAcceptHandler = invite.NewInviteAcceptHandler
-	NewInviteCreateHandler = invite.NewInviteCreateHandler
-	NewInviteDeleteHandler = invite.NewInviteDeleteHandler
-	NewInvitesListHandler = invite.NewInvitesListHandler
-}

+ 43 - 0
api/server/handlers/invite/list.go

@@ -0,0 +1,43 @@
+package invite
+
+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/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type InvitesListHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewInvitesListHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) http.Handler {
+	return &InvitesListHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *InvitesListHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	invites, err := c.Repo().Invite().ListInvitesByProjectID(project.ID)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var res types.ListInvitesResponse = make([]*types.Invite, 0)
+
+	for _, invite := range invites {
+		res = append(res, invite.ToInviteType())
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 43 - 0
api/server/handlers/invite/update_role.go

@@ -0,0 +1,43 @@
+package invite
+
+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/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type InviteUpdateRoleHandler struct {
+	handlers.PorterHandlerReader
+}
+
+func NewInviteUpdateRoleHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+) http.Handler {
+	return &InviteUpdateRoleHandler{
+		PorterHandlerReader: handlers.NewDefaultPorterHandler(config, decoderValidator, nil),
+	}
+}
+
+func (c *InviteUpdateRoleHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	invite, _ := r.Context().Value(types.InviteScope).(*models.Invite)
+
+	request := &types.UpdateInviteRoleRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	invite.Kind = request.Kind
+
+	if _, err := c.Repo().Invite().UpdateInvite(invite); err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	}
+
+	w.WriteHeader(http.StatusOK)
+}

+ 85 - 36
api/server/handlers/user/create.go

@@ -1,9 +1,14 @@
 package user
 
 import (
+	"errors"
 	"fmt"
 	"net/http"
 
+	"gorm.io/gorm"
+
+	"github.com/porter-dev/porter/internal/telemetry"
+
 	"github.com/porter-dev/porter/api/server/authn"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -31,24 +36,79 @@ func NewUserCreateHandler(
 }
 
 func (u *UserCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	request := &types.CreateUserRequest{}
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-user-create")
+	defer span.End()
 
-	ok := u.DecodeAndValidate(w, r, request)
+	r = r.Clone(ctx)
 
+	request := &types.CreateUserRequest{}
+	ok := u.DecodeAndValidate(w, r, request)
 	if !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding and validating request")
+		u.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
-	user := &models.User{
-		Email:       request.Email,
-		Password:    request.Password,
-		FirstName:   request.FirstName,
-		LastName:    request.LastName,
-		CompanyName: request.CompanyName,
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "email", Value: request.Email})
+	if request.Email == "" {
+		err := fmt.Errorf("email is required")
+		u.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	newUser := &models.User{
+		Email:        request.Email,
+		Password:     request.Password,
+		FirstName:    request.FirstName,
+		LastName:     request.LastName,
+		CompanyName:  request.CompanyName,
+		AuthProvider: request.AuthProvider,
+		ExternalId:   request.ExternalId,
+	}
+
+	if request.AuthProvider != "" {
+		telemetry.WithAttributes(span,
+			telemetry.AttributeKV{Key: "auth-provider", Value: request.AuthProvider},
+			telemetry.AttributeKV{Key: "external-id", Value: request.ExternalId},
+		)
+
+		user, err := u.Repo().User().ReadUserByAuthProvider(request.AuthProvider, request.ExternalId)
+		if err != nil {
+			if !errors.Is(err, gorm.ErrRecordNotFound) {
+				telemetry.Error(ctx, span, err, "error reading user by auth provider")
+				u.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "new-user", Value: true})
+
+			newUser, err = u.Repo().User().CreateUser(newUser)
+			if err != nil {
+				telemetry.Error(ctx, span, err, "error creating user")
+				u.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+
+			user = newUser
+		}
+
+		u.Config().AnalyticsClient.Identify(analytics.CreateSegmentIdentifyUser(user))
+
+		u.Config().AnalyticsClient.Track(analytics.UserCreateTrack(&analytics.UserCreateTrackOpts{
+			UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
+			Email:               user.Email,
+			FirstName:           user.FirstName,
+			LastName:            user.LastName,
+			CompanyName:         user.CompanyName,
+			ReferralMethod:      request.ReferralMethod,
+		}))
+
+		u.WriteResult(w, r, user.ToUserType())
+		return
 	}
 
 	// check if user exists
-	doesExist := doesUserExist(u.Repo().User(), user)
+	doesExist := doesUserExist(u.Repo().User(), newUser)
 
 	if doesExist {
 		err := fmt.Errorf("email already taken")
@@ -62,64 +122,52 @@ func (u *UserCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	}
 
 	// hash the password using bcrypt
-	hashedPw, err := bcrypt.GenerateFromPassword([]byte(user.Password), 8)
+	hashedPw, err := bcrypt.GenerateFromPassword([]byte(newUser.Password), 8)
 	if err != nil {
 		u.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
-	user.Password = string(hashedPw)
+	newUser.Password = string(hashedPw)
 
 	// write the user to the db
-	user, err = u.Repo().User().CreateUser(user)
+	newUser, err = u.Repo().User().CreateUser(newUser)
 	if err != nil {
 		u.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
-	err = addUserToDefaultProject(u.Config(), user)
+	err = addUserToDefaultProject(u.Config(), newUser)
+
 	if err != nil {
 		u.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
 	// save the user as authenticated in the session
-	redirect, err := authn.SaveUserAuthenticated(w, r, u.Config(), user)
+	redirect, err := authn.SaveUserAuthenticated(w, r, u.Config(), newUser)
 	if err != nil {
 		u.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
 	// non-fatal send email verification
-	if !user.EmailVerified {
-		err = startEmailVerification(u.Config(), w, r, user)
-		if err != nil {
-			u.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
-		}
-	}
-
-	// create referral if referred by another user
-	if request.ReferredBy != "" {
-		referral := &models.Referral{
-			Code:           request.ReferredBy,
-			ReferredUserID: user.ID,
-			Status:         models.ReferralStatusSignedUp,
-		}
+	if !newUser.EmailVerified {
+		err = startEmailVerification(u.Config(), w, r, newUser)
 
-		_, err = u.Repo().Referral().CreateReferral(referral)
 		if err != nil {
 			u.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
 		}
 	}
 
-	u.Config().AnalyticsClient.Identify(analytics.CreateSegmentIdentifyUser(user))
+	u.Config().AnalyticsClient.Identify(analytics.CreateSegmentIdentifyUser(newUser))
 
 	u.Config().AnalyticsClient.Track(analytics.UserCreateTrack(&analytics.UserCreateTrackOpts{
-		UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
-		Email:               user.Email,
-		FirstName:           user.FirstName,
-		LastName:            user.LastName,
-		CompanyName:         user.CompanyName,
+		UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(newUser.ID),
+		Email:               newUser.Email,
+		FirstName:           newUser.FirstName,
+		LastName:            newUser.LastName,
+		CompanyName:         newUser.CompanyName,
 		ReferralMethod:      request.ReferralMethod,
 	}))
 
@@ -128,7 +176,7 @@ func (u *UserCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	u.WriteResult(w, r, user.ToUserType())
+	u.WriteResult(w, r, newUser.ToUserType())
 }
 
 func doesUserExist(userRepo repository.UserRepository, user *models.User) bool {
@@ -157,6 +205,7 @@ func addUserToDefaultProject(config *config.Config, user *models.User) error {
 					Kind:      types.RoleAdmin,
 				},
 			})
+
 			if err != nil {
 				return err
 			}

+ 117 - 0
api/server/handlers/user/invite_list.go

@@ -0,0 +1,117 @@
+package user
+
+import (
+	"errors"
+	"net/http"
+	"time"
+
+	"gorm.io/gorm"
+
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/internal/telemetry"
+
+	"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 UserListInvitesHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewUserListInvitesHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *UserListInvitesHandler {
+	return &UserListInvitesHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+type ListInvitesResponse []ProjectInvite
+
+type ProjectInvite struct {
+	Id      uint    `json:"id"`
+	Status  string  `json:"status"`
+	Project Project `json:"project"`
+	Inviter User    `json:"inviter"`
+}
+
+type Project struct {
+	ID   uint   `json:"id"`
+	Name string `json:"name"`
+}
+
+type User struct {
+	Email   string `json:"email"`
+	Company string `json:"company"`
+}
+
+func (a *UserListInvitesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-user-list-invites")
+	defer span.End()
+
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+
+	if user == nil {
+		err := telemetry.Error(ctx, span, nil, "user not found in context")
+		a.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	invites, err := a.Repo().Invite().ListInvitesByEmail(user.Email)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error listing invites by email")
+		a.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var res ListInvitesResponse
+
+	for _, invite := range invites {
+		if invite.Status != "pending" || (invite.Expiry != nil && time.Since(*invite.Expiry) > 0) {
+			continue
+		}
+
+		project, err := a.Repo().Project().ReadProject(invite.ProjectID)
+		if err != nil {
+			if !errors.Is(err, gorm.ErrRecordNotFound) {
+				err := telemetry.Error(ctx, span, err, "error reading project")
+				a.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+
+			// if the project is not found, skip
+			continue
+		}
+
+		inviter, err := a.Repo().User().ReadUser(invite.InvitingUserID)
+		if err != nil {
+			if !errors.Is(err, gorm.ErrRecordNotFound) {
+				err := telemetry.Error(ctx, span, err, "error reading user")
+				a.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+
+			// if user who originally invited is not found, skip
+			continue
+		}
+
+		res = append(res, ProjectInvite{
+			Id:     invite.ID,
+			Status: string(invite.Status),
+			Project: Project{
+				ID:   project.ID,
+				Name: project.Name,
+			},
+			Inviter: User{
+				Email:   inviter.Email,
+				Company: inviter.CompanyName,
+			},
+		})
+	}
+
+	a.WriteResult(w, r, res)
+}

+ 145 - 0
api/server/handlers/user/invite_respond.go

@@ -0,0 +1,145 @@
+package user
+
+import (
+	"errors"
+	fmt "fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/shared"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"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"
+	"github.com/porter-dev/porter/internal/telemetry"
+	"gorm.io/gorm"
+)
+
+type InviteResponseHandler struct {
+	handlers.PorterHandlerReader
+}
+
+func NewInviteResponseHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+) http.Handler {
+	return &InviteResponseHandler{
+		PorterHandlerReader: handlers.NewDefaultPorterHandler(config, decoderValidator, nil),
+	}
+}
+
+type InviteResponseRequest struct {
+	AcceptedInviteIds []uint `json:"accepted_invite_ids"`
+	DeclinedInviteIds []uint `json:"declined_invite_ids"`
+}
+
+func (c *InviteResponseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-invite-response")
+	defer span.End()
+
+	user, _ := ctx.Value(types.UserScope).(*models.User)
+
+	request := &InviteResponseRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding and validating request")
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	invites, err := c.Repo().Invite().ListInvitesByEmail(user.Email)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error listing invites by email")
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// create a map of pending invites by id
+	invitesById := map[uint]*models.Invite{}
+	for _, invite := range invites {
+		// only consider pending invites
+		if invite.Status == models.InvitePending {
+			invitesById[invite.ID] = invite
+		}
+	}
+
+	fmt.Println("dgt invitesById", invitesById)
+
+	// accept invites and create roles in project
+	for _, id := range request.AcceptedInviteIds {
+		if invite, ok := invitesById[id]; ok {
+			fmt.Println("dgt invite found", invite)
+
+			project, err := c.Repo().Project().ReadProject(invite.ProjectID)
+			if err != nil {
+				// if the project is not found, skip
+				if errors.Is(err, gorm.ErrRecordNotFound) {
+					continue
+				}
+				err = telemetry.Error(ctx, span, err, "error reading project")
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+
+			fmt.Println("dgt project found", project)
+
+			if invite.Kind == "" {
+				invite.Kind = models.RoleDeveloper
+			}
+
+			role := &models.Role{
+				Role: types.Role{
+					UserID:    user.ID,
+					ProjectID: invite.ProjectID,
+					Kind:      types.RoleKind(invite.Kind),
+				},
+			}
+
+			if _, err := c.Repo().Project().ReadProjectRole(project.ID, user.ID); err != nil {
+				fmt.Println("dgt role not found")
+
+				if !errors.Is(err, gorm.ErrRecordNotFound) {
+					err = telemetry.Error(ctx, span, err, "error reading project role")
+					c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+					return
+				}
+				fmt.Println("edgt role creating")
+				telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "new-role", Value: true})
+				// only create if no role is found yet
+				if role, err = c.Repo().Project().CreateProjectRole(project, role); err != nil {
+					err = telemetry.Error(ctx, span, err, "error creating project role")
+					c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+					return
+				}
+				fmt.Println("dgt role created")
+
+			}
+
+			// update the invite
+			invite.UserID = user.ID
+			invite.Status = models.InviteAccepted
+
+			if _, err = c.Repo().Invite().UpdateInvite(invite); err != nil {
+				err = telemetry.Error(ctx, span, err, "error updating invite")
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+
+		}
+	}
+
+	// decline invites
+	for _, id := range request.DeclinedInviteIds {
+		if invite, ok := invitesById[id]; ok {
+			invite.Status = models.InviteDeclined
+
+			if _, err = c.Repo().Invite().UpdateInvite(invite); err != nil {
+				err = telemetry.Error(ctx, span, err, "error updating invite")
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+		}
+	}
+
+	return
+}

+ 141 - 0
api/server/handlers/user/migrate.go

@@ -0,0 +1,141 @@
+package user
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"strconv"
+
+	"github.com/davecgh/go-spew/spew"
+
+	ory "github.com/ory/client-go"
+
+	"github.com/porter-dev/porter/internal/telemetry"
+
+	"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"
+)
+
+type MigrateUsersHandler struct {
+	handlers.PorterHandler
+}
+
+func NewMigrateUsersHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *MigrateUsersHandler {
+	return &MigrateUsersHandler{
+		PorterHandler: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (u *MigrateUsersHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-migrate-users")
+	defer span.End()
+
+	r = r.Clone(ctx)
+
+	users, err := u.Repo().User().ListUsers()
+	if err != nil {
+		err := telemetry.Error(ctx, span, nil, "error listing users")
+		u.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	for _, user := range users {
+		createIdentityBody := ory.CreateIdentityBody{
+			SchemaId: "preset://email",
+			Traits:   map[string]interface{}{"email": user.Email},
+		}
+
+		if user.EmailVerified {
+			createIdentityBody.VerifiableAddresses = []ory.VerifiableIdentityAddress{
+				{
+					Value:    user.Email,
+					Verified: true,
+					Via:      "email",
+					Status:   "completed",
+				},
+			}
+		}
+
+		if user.Password != "" {
+			password := user.Password
+			createIdentityBody.Credentials = &ory.IdentityWithCredentials{
+				Oidc: nil,
+				Password: &ory.IdentityWithCredentialsPassword{
+					Config: &ory.IdentityWithCredentialsPasswordConfig{
+						HashedPassword: &password,
+					},
+				},
+				AdditionalProperties: nil,
+			}
+		} else if user.GithubUserID != 0 {
+			createIdentityBody.Credentials = &ory.IdentityWithCredentials{
+				Oidc: &ory.IdentityWithCredentialsOidc{
+					Config: &ory.IdentityWithCredentialsOidcConfig{
+						Config: nil,
+						Providers: []ory.IdentityWithCredentialsOidcConfigProvider{
+							{
+								Provider: "github",
+								Subject:  strconv.Itoa(int(user.GithubUserID)),
+							},
+						},
+					},
+				},
+			}
+		} else if user.GoogleUserID != "" {
+			createIdentityBody.Credentials = &ory.IdentityWithCredentials{
+				Oidc: &ory.IdentityWithCredentialsOidc{
+					Config: &ory.IdentityWithCredentialsOidcConfig{
+						Config: nil,
+						Providers: []ory.IdentityWithCredentialsOidcConfigProvider{
+							{
+								Provider: "google",
+								Subject:  user.GoogleUserID,
+							},
+						},
+					},
+				},
+			}
+		} else {
+			continue
+		}
+
+		createdIdentity, resp, err := u.Config().Ory.IdentityAPI.CreateIdentity(context.WithValue(ctx, ory.ContextAccessToken, u.Config().OryApiKey)).CreateIdentityBody(createIdentityBody).Execute()
+		if err != nil {
+			switch resp.StatusCode {
+			// identity already exists, so we need to list the identities and find the one that matches
+			case 409:
+				identities, _, err := u.Config().Ory.IdentityAPI.ListIdentities(context.WithValue(ctx, ory.ContextAccessToken, u.Config().OryApiKey)).CredentialsIdentifier(user.Email).Execute()
+				if err != nil {
+					fmt.Printf("Error when calling `IdentityApi.ListIdentities``: %v\n", err)
+					continue
+				}
+
+				if len(identities) != 1 {
+					fmt.Printf("Error when calling `IdentityApi.ListIdentities``: expected 1 identity, got %d\n", len(identities))
+					continue
+				}
+
+				createdIdentity = &identities[0]
+			default:
+				fmt.Printf("Error when calling `IdentityApi.CreateIdentity``: %v\n", err)
+				continue
+			}
+		}
+
+		user.AuthProvider = "ory"
+		user.ExternalId = createdIdentity.Id
+
+		_, err = u.Repo().User().UpdateUser(user)
+		if err != nil {
+			fmt.Printf("Error when updating user: %v\n", err)
+		}
+
+		spew.Dump(createdIdentity)
+	}
+}

+ 24 - 0
api/server/router/base.go

@@ -197,6 +197,30 @@ func GetBaseRoutes(
 		Router:   r,
 	})
 
+	// Get /api/users/migrate -> user.NewMigrateUsersHandler
+	migrateUsersEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/users/migrate",
+			},
+		},
+	)
+
+	migrateUsersHandler := user.NewMigrateUsersHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: migrateUsersEndpoint,
+		Handler:  migrateUsersHandler,
+		Router:   r,
+	})
+
 	// POST /api/login -> user.NewUserLoginHandler
 	loginUserEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 0 - 26
api/server/router/invite.go

@@ -112,32 +112,6 @@ func getInviteRoutes(
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/invites/{token} -> invite.NewInviteAcceptHandler
-	acceptEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbGet,
-			Method: types.HTTPVerbGet,
-			Path: &types.Path{
-				Parent:       basePath,
-				RelativePath: "/invites/{token}",
-			},
-			// only user scope is needed here. adding the project scope will prevent the user
-			// from joining the project, since they don't have a role in the project yet.
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-			},
-			ShouldRedirect: true,
-		},
-	)
-
-	acceptHandler := invite.NewInviteAcceptHandler(config)
-
-	routes = append(routes, &router.Route{
-		Endpoint: acceptEndpoint,
-		Handler:  acceptHandler,
-		Router:   r,
-	})
-
 	// POST /api/projects/{project_id}/invites/{invite_id} -> invite.NewInviteUpdateRoleHandler
 	updateRoleEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 50 - 1
api/server/router/user.go

@@ -3,11 +3,12 @@ package router
 import (
 	"fmt"
 
+	"github.com/porter-dev/porter/api/server/handlers/user"
+
 	"github.com/go-chi/chi/v5"
 	"github.com/porter-dev/porter/api/server/handlers/gitinstallation"
 	"github.com/porter-dev/porter/api/server/handlers/project"
 	"github.com/porter-dev/porter/api/server/handlers/template"
-	"github.com/porter-dev/porter/api/server/handlers/user"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/router"
@@ -170,6 +171,54 @@ func getUserRoutes(
 		Router:   r,
 	})
 
+	// GET /api/users/invites -> user.NewUserGetCurrentHandler
+	listUserInvitesEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/users/invites",
+			},
+			Scopes: []types.PermissionScope{types.UserScope},
+		},
+	)
+
+	listUserInvitesHandler := user.NewUserListInvitesHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listUserInvitesEndpoint,
+		Handler:  listUserInvitesHandler,
+		Router:   r,
+	})
+
+	// POST /api/users/invites/respond -> user.NewUserGetCurrentHandler
+	userInvitesRespondEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/users/invites/response",
+			},
+			Scopes: []types.PermissionScope{types.UserScope},
+		},
+	)
+
+	userInvitesRespondHandler := user.NewInviteResponseHandler(
+		config,
+		factory.GetDecoderValidator(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: userInvitesRespondEndpoint,
+		Handler:  userInvitesRespondHandler,
+		Router:   r,
+	})
+
 	// DELETE /api/users/current -> user.NewUserDeleteHandler
 	deleteUserEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

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

@@ -2,6 +2,7 @@ package config
 
 import (
 	"github.com/gorilla/sessions"
+	ory "github.com/ory/client-go"
 	"github.com/porter-dev/api-contracts/generated/go/porter/v1/porterv1connect"
 	"github.com/porter-dev/porter/api/server/shared/apierrors/alerter"
 	"github.com/porter-dev/porter/api/server/shared/config/env"
@@ -120,6 +121,9 @@ type Config struct {
 	EnableCAPIProvisioner bool
 
 	TelemetryConfig telemetry.TracerConfig
+
+	Ory       *ory.APIClient
+	OryApiKey string
 }
 
 type ConfigLoader interface {

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

@@ -169,6 +169,9 @@ type ServerConf struct {
 	TelemetryName string `env:"TELEMETRY_NAME"`
 	// TelemetryCollectorURL is the URL (host:port) for collecting spans
 	TelemetryCollectorURL string `env:"TELEMETRY_COLLECTOR_URL,default=localhost:4317"`
+
+	OryUrl    string `env:"ORY_URL,default=http://localhost:4000"`
+	OryApiKey string `env:"ORY_API_KEY"`
 }
 
 // DBConf is the database configuration: if generated from environment variables,

+ 9 - 0
api/server/shared/config/loader/loader.go

@@ -11,6 +11,7 @@ import (
 	"strconv"
 
 	gorillaws "github.com/gorilla/websocket"
+	ory "github.com/ory/client-go"
 	"github.com/porter-dev/api-contracts/generated/go/porter/v1/porterv1connect"
 	"github.com/porter-dev/porter/api/server/shared/apierrors/alerter"
 	"github.com/porter-dev/porter/api/server/shared/config"
@@ -389,6 +390,14 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 	}
 	res.Logger.Info().Msg("Created billing manager")
 
+	c := ory.NewConfiguration()
+	c.Servers = ory.ServerConfigurations{{
+		URL: InstanceEnvConf.ServerConf.OryUrl,
+	}}
+
+	res.Ory = ory.NewAPIClient(c)
+	res.OryApiKey = InstanceEnvConf.ServerConf.OryApiKey
+
 	return res, nil
 }
 

+ 8 - 6
api/types/invite.go

@@ -5,12 +5,14 @@ const (
 )
 
 type Invite struct {
-	ID       uint   `json:"id"`
-	Token    string `json:"token"`
-	Expired  bool   `json:"expired"`
-	Email    string `json:"email"`
-	Accepted bool   `json:"accepted"`
-	Kind     string `json:"kind"`
+	ID             uint   `json:"id"`
+	Token          string `json:"token"`
+	Expired        bool   `json:"expired"`
+	Email          string `json:"email"`
+	Accepted       bool   `json:"accepted"`
+	Kind           string `json:"kind"`
+	InvitingUserID uint   `json:"inviting_user_id"`
+	Status         string `json:"status"`
 }
 
 type GetInviteResponse Invite

+ 9 - 7
api/types/user.go

@@ -10,14 +10,16 @@ type User struct {
 }
 
 type CreateUserRequest struct {
-	Email          string `json:"email" form:"required,max=255,email"`
-	Password       string `json:"password" form:"required,max=255"`
-	FirstName      string `json:"first_name" form:"required,max=255"`
-	LastName       string `json:"last_name" form:"required,max=255"`
-	CompanyName    string `json:"company_name" form:"required,max=255"`
-	ReferralMethod string `json:"referral_method" form:"max=255"`
+	Email          string `json:"email"`
+	Password       string `json:"password"`
+	FirstName      string `json:"first_name"`
+	LastName       string `json:"last_name"`
+	CompanyName    string `json:"company_name"`
+	ReferralMethod string `json:"referral_method"`
 	// ReferredBy is the referral code of the project from which this user was referred
-	ReferredBy string `json:"referred_by_code" form:"max=255"`
+	ReferredBy   string `json:"referred_by_code"`
+	AuthProvider string `json:"auth_provider"`
+	ExternalId   string `json:"external_id"`
 }
 
 type CreateUserResponse User

+ 181 - 11
dashboard/package-lock.json

@@ -13,6 +13,8 @@
         "@loadable/component": "^5.15.2",
         "@material-ui/core": "^4.11.3",
         "@material-ui/lab": "^4.0.0-alpha.61",
+        "@ory/client": "^1.9.0",
+        "@ory/elements": "^0.2.0",
         "@react-spring/web": "^9.6.1",
         "@sentry/react": "^6.13.2",
         "@sentry/tracing": "^6.13.2",
@@ -20,6 +22,7 @@
         "@stripe/stripe-js": "^3.0.10",
         "@tanstack/react-query": "^4.13.0",
         "@tanstack/react-query-devtools": "^4.13.5",
+        "@tanstack/react-table": "^8.15.3",
         "@visx/axis": "^3.3.0",
         "@visx/curve": "^3.3.0",
         "@visx/event": "^3.3.0",
@@ -2685,6 +2688,33 @@
         "node": ">=10"
       }
     },
+    "node_modules/@ory/client": {
+      "version": "1.9.0",
+      "resolved": "https://registry.npmjs.org/@ory/client/-/client-1.9.0.tgz",
+      "integrity": "sha512-O4a1ijgJtMNIA+ZmWUmCodxX13ID72hOaCB0b9FQGQBzuFgF2x/Yq5D43nrMYZaDtvDvja8J1XIXhUkjz1TDOw==",
+      "dependencies": {
+        "axios": "^1.6.1"
+      }
+    },
+    "node_modules/@ory/client/node_modules/axios": {
+      "version": "1.6.8",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz",
+      "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==",
+      "dependencies": {
+        "follow-redirects": "^1.15.6",
+        "form-data": "^4.0.0",
+        "proxy-from-env": "^1.1.0"
+      }
+    },
+    "node_modules/@ory/elements": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/@ory/elements/-/elements-0.2.0.tgz",
+      "integrity": "sha512-VRhomM6rm+GOWcYzoe2dleDuUXXuzMSPt8njbRPgqwN2NGfeloaHiVmrKUJ1xoztQA2gsGfp7eH/3StfBmPpkg==",
+      "engines": {
+        "node": ">=16.16.0",
+        "npm": ">=8.11.0"
+      }
+    },
     "node_modules/@pmmmwh/react-refresh-webpack-plugin": {
       "version": "0.4.3",
       "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.4.3.tgz",
@@ -3037,6 +3067,37 @@
         "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
       }
     },
+    "node_modules/@tanstack/react-table": {
+      "version": "8.15.3",
+      "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.15.3.tgz",
+      "integrity": "sha512-aocQ4WpWiAh7R+yxNp+DGQYXeVACh5lv2kk96DjYgFiHDCB0cOFoYMT/pM6eDOzeMXR9AvPoLeumTgq8/0qX+w==",
+      "dependencies": {
+        "@tanstack/table-core": "8.15.3"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/tannerlinsley"
+      },
+      "peerDependencies": {
+        "react": ">=16.8",
+        "react-dom": ">=16.8"
+      }
+    },
+    "node_modules/@tanstack/table-core": {
+      "version": "8.15.3",
+      "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.15.3.tgz",
+      "integrity": "sha512-wOgV0HfEvuMOv8RlqdR9MdNNqq0uyvQtP39QOvGlggHvIObOE4exS+D5LGO8LZ3LUXxId2IlUKcHDHaGujWhUg==",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/tannerlinsley"
+      }
+    },
     "node_modules/@testing-library/dom": {
       "version": "6.16.0",
       "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-6.16.0.tgz",
@@ -5290,6 +5351,11 @@
         "has-symbols": "^1.0.3"
       }
     },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
+    },
     "node_modules/atob": {
       "version": "2.1.2",
       "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
@@ -6362,6 +6428,17 @@
       "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
       "dev": true
     },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
     "node_modules/commander": {
       "version": "2.20.3",
       "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
@@ -7271,6 +7348,14 @@
         "robust-predicates": "^3.0.0"
       }
     },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
     "node_modules/delegate": {
       "version": "3.2.0",
       "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz",
@@ -9239,9 +9324,9 @@
       }
     },
     "node_modules/follow-redirects": {
-      "version": "1.15.2",
-      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
-      "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==",
+      "version": "1.15.6",
+      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
+      "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==",
       "funding": [
         {
           "type": "individual",
@@ -9275,6 +9360,19 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/form-data": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
+      "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
     "node_modules/forwarded": {
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -12079,7 +12177,6 @@
       "version": "1.52.0",
       "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
       "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
-      "dev": true,
       "engines": {
         "node": ">= 0.6"
       }
@@ -12088,7 +12185,6 @@
       "version": "2.1.35",
       "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
       "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
-      "dev": true,
       "dependencies": {
         "mime-db": "1.52.0"
       },
@@ -13423,6 +13519,11 @@
       "resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-2.5.0.tgz",
       "integrity": "sha512-f1us0OsVAJ3tdIMXGQx2lmseYS4YXe4W+sKF5g5ww/jV+5ogMadPt+sIZ+88Ga9kvMJsrRNWzCrKPpr6pMWYbA=="
     },
+    "node_modules/proxy-from-env": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+      "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
+    },
     "node_modules/prr": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
@@ -20237,6 +20338,31 @@
         "rimraf": "^3.0.2"
       }
     },
+    "@ory/client": {
+      "version": "1.9.0",
+      "resolved": "https://registry.npmjs.org/@ory/client/-/client-1.9.0.tgz",
+      "integrity": "sha512-O4a1ijgJtMNIA+ZmWUmCodxX13ID72hOaCB0b9FQGQBzuFgF2x/Yq5D43nrMYZaDtvDvja8J1XIXhUkjz1TDOw==",
+      "requires": {
+        "axios": "^1.6.1"
+      },
+      "dependencies": {
+        "axios": {
+          "version": "1.6.8",
+          "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz",
+          "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==",
+          "requires": {
+            "follow-redirects": "^1.15.6",
+            "form-data": "^4.0.0",
+            "proxy-from-env": "^1.1.0"
+          }
+        }
+      }
+    },
+    "@ory/elements": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/@ory/elements/-/elements-0.2.0.tgz",
+      "integrity": "sha512-VRhomM6rm+GOWcYzoe2dleDuUXXuzMSPt8njbRPgqwN2NGfeloaHiVmrKUJ1xoztQA2gsGfp7eH/3StfBmPpkg=="
+    },
     "@pmmmwh/react-refresh-webpack-plugin": {
       "version": "0.4.3",
       "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.4.3.tgz",
@@ -20462,6 +20588,19 @@
         "use-sync-external-store": "^1.2.0"
       }
     },
+    "@tanstack/react-table": {
+      "version": "8.15.3",
+      "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.15.3.tgz",
+      "integrity": "sha512-aocQ4WpWiAh7R+yxNp+DGQYXeVACh5lv2kk96DjYgFiHDCB0cOFoYMT/pM6eDOzeMXR9AvPoLeumTgq8/0qX+w==",
+      "requires": {
+        "@tanstack/table-core": "8.15.3"
+      }
+    },
+    "@tanstack/table-core": {
+      "version": "8.15.3",
+      "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.15.3.tgz",
+      "integrity": "sha512-wOgV0HfEvuMOv8RlqdR9MdNNqq0uyvQtP39QOvGlggHvIObOE4exS+D5LGO8LZ3LUXxId2IlUKcHDHaGujWhUg=="
+    },
     "@testing-library/dom": {
       "version": "6.16.0",
       "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-6.16.0.tgz",
@@ -22374,6 +22513,11 @@
         "has-symbols": "^1.0.3"
       }
     },
+    "asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
+    },
     "atob": {
       "version": "2.1.2",
       "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
@@ -23238,6 +23382,14 @@
       "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
       "dev": true
     },
+    "combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "requires": {
+        "delayed-stream": "~1.0.0"
+      }
+    },
     "commander": {
       "version": "2.20.3",
       "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
@@ -23979,6 +24131,11 @@
         "robust-predicates": "^3.0.0"
       }
     },
+    "delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
+    },
     "delegate": {
       "version": "3.2.0",
       "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz",
@@ -25535,9 +25692,9 @@
       }
     },
     "follow-redirects": {
-      "version": "1.15.2",
-      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
-      "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA=="
+      "version": "1.15.6",
+      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
+      "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA=="
     },
     "for-each": {
       "version": "0.3.3",
@@ -25554,6 +25711,16 @@
       "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==",
       "dev": true
     },
+    "form-data": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
+      "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
+      "requires": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "mime-types": "^2.1.12"
+      }
+    },
     "forwarded": {
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -27639,14 +27806,12 @@
     "mime-db": {
       "version": "1.52.0",
       "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
-      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
-      "dev": true
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
     },
     "mime-types": {
       "version": "2.1.35",
       "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
       "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
-      "dev": true,
       "requires": {
         "mime-db": "1.52.0"
       }
@@ -28681,6 +28846,11 @@
       "resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-2.5.0.tgz",
       "integrity": "sha512-f1us0OsVAJ3tdIMXGQx2lmseYS4YXe4W+sKF5g5ww/jV+5ogMadPt+sIZ+88Ga9kvMJsrRNWzCrKPpr6pMWYbA=="
     },
+    "proxy-from-env": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+      "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
+    },
     "prr": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",

+ 3 - 0
dashboard/package.json

@@ -8,6 +8,8 @@
     "@loadable/component": "^5.15.2",
     "@material-ui/core": "^4.11.3",
     "@material-ui/lab": "^4.0.0-alpha.61",
+    "@ory/client": "^1.9.0",
+    "@ory/elements": "^0.2.0",
     "@react-spring/web": "^9.6.1",
     "@sentry/react": "^6.13.2",
     "@sentry/tracing": "^6.13.2",
@@ -15,6 +17,7 @@
     "@stripe/stripe-js": "^3.0.10",
     "@tanstack/react-query": "^4.13.0",
     "@tanstack/react-query-devtools": "^4.13.5",
+    "@tanstack/react-table": "^8.15.3",
     "@visx/axis": "^3.3.0",
     "@visx/curve": "^3.3.0",
     "@visx/event": "^3.3.0",

+ 204 - 0
dashboard/src/components/UserInviteModal.tsx

@@ -0,0 +1,204 @@
+import React, {useContext, useEffect, useMemo, useState} from "react";
+import axios from "axios";
+import styled from "styled-components";
+
+import {type Invite, inviteValidator} from "../lib/invites/types";
+import InviteRow from "../main/home/app-dashboard/validate-apply/app-settings/EnvGroupRow";
+import type { PopulatedEnvGroup } from "../main/home/app-dashboard/validate-apply/app-settings/types";
+import Button from "./porter/Button";
+import Container from "./porter/Container";
+import Modal from "./porter/Modal";
+import SelectableList from "./porter/SelectableList";
+import Spacer from "./porter/Spacer";
+import Text from "./porter/Text";
+import {Context} from "../shared/Context";
+import type {InviteType} from "../shared/types";
+import api from "../shared/api";
+import type {Column} from "react-table";
+import CopyToClipboard from "./CopyToClipboard";
+import Loading from "./Loading";
+import Heading from "./form-components/Heading";
+import Helper from "./form-components/Helper";
+import PermissionGroup from "../main/home/project-settings/PermissionGroup";
+import RoleModal from "../main/home/project-settings/RoleModal";
+import InputRow from "./form-components/InputRow";
+import RadioSelector from "./RadioSelector";
+import Table from "./OldTable";
+import {Collaborator} from "../main/home/project-settings/InviteList";
+import {SubmitButton} from "../main/home/cluster-dashboard/stacks/launch/components/styles";
+import {AuthnContext} from "../shared/auth/AuthnContext";
+
+type Props = {
+  invites: Invite[];
+  closeModal: () => void;
+};
+
+type InviteMap = Record<
+  number,
+  {
+    status: "pending" | "accepted" | "declined" | "expired";
+  }
+>;
+
+const UserInviteModal: React.FC<Props> = ({ invites, closeModal }) => {
+  const { checkInvites } = useContext(AuthnContext);
+  const [inviteMap, setInviteMap] = useState<InviteMap>({});
+  const [errorText, setErrorText] = useState<string>("");
+
+  useEffect(() => {
+    invites.forEach((invite) => {
+      if (!inviteMap[invite.id] && invite.status === "pending") {
+        setInviteMap({
+          ...inviteMap,
+          [invite.id]: {
+              status: "pending",
+          },
+        });
+      }
+    });
+  }, [invites]);
+
+  const acceptInvite = (invite: Invite): void => {
+    setInviteMap({
+      ...inviteMap,
+      [invite.id]: {
+          status: "accepted",
+      },
+    });
+  };
+
+  const declineInvite = (invite: Invite): void => {
+    setInviteMap({
+      ...inviteMap,
+      [invite.id]: {
+          status: "declined",
+      },
+    });
+  };
+
+  const isDeclined = (invite: Invite): boolean => {
+    return inviteMap[invite.id]?.status === "declined";
+  };
+
+  const isAccepted = (invite: Invite): boolean => {
+    return inviteMap[invite.id]?.status === "accepted";
+  };
+
+  return (
+    <Modal>
+      <Text size={16}>Pending project invites</Text>
+      <Spacer height="15px" />
+      <>
+        <Text color="helper">
+          Accept or decline all pending project invites to proceed.
+        </Text>
+        <Spacer y={1} />
+        <ScrollableContainer>
+          <InviteList>
+            {invites.map((invite, i) => (
+              <Container row spaced key={i}>
+                <Container>{invite.project.name}</Container>
+                <Container>{invite.inviter.email}</Container>
+                <SelectedIndicator
+                  onClick={() => {
+                    declineInvite(invite);
+                  }}
+                  isSelected={isDeclined(invite)}
+                >
+                    <Check className="material-icons">close</Check>
+                </SelectedIndicator>
+                <SelectedIndicator
+                  onClick={() => {
+                    acceptInvite(invite);
+                  }}
+                  isSelected={isAccepted(invite)}
+                >
+                    <Check className="material-icons">check</Check>
+                </SelectedIndicator>
+              </Container>
+            ))}
+          </InviteList>
+          <Spacer y={1} />
+          <Button
+            onClick={() => {
+                api.respondUserInvites(
+                    "<token>",
+                    {
+                      accepted_invite_ids: Object.entries(inviteMap).filter(([_, invite]) => invite.status === "accepted").map(([id]) => parseInt(id)),
+                      declined_invite_ids: Object.entries(inviteMap).filter(([_, invite]) => invite.status === "declined").map(([id]) => parseInt(id)),
+                    },
+                    {}
+                )
+                    .then(() => {
+                      setErrorText("");
+                        console.log("here")
+                        checkInvites()
+                        console.log("there")
+                        closeModal()
+                    })
+                    .catch((err) => {
+                        console.log(err)
+                        if (axios.isAxiosError(err) && err.response?.data?.error) {
+                            setErrorText(err.response?.data?.error);
+                            return;
+                        }
+                        setErrorText(
+                            "An error occurred responding to project invites. Please try again."
+                        );
+                    })
+            }}
+            errorText={errorText}
+            disabled={Object.values(inviteMap).filter((invite) => invite.status === "pending").length > 0}
+            >Respond to invites</Button>
+        </ScrollableContainer>
+      </>
+    </Modal>
+  );
+};
+
+export default UserInviteModal;
+
+const Check = styled.i`
+  color: #ffffff;
+  background: #ffffff33;
+  width: 24px;
+  height: 23px;
+  z-index: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 50%;
+`;
+
+const SelectedIndicator = styled.div<{ isSelected: boolean }>`
+  width: 25px;
+  height: 25px;
+  border: 1px solid ${(props) => (props.isSelected ? "#ffffff" : "#ffffff55")};
+  border-radius: 50%;
+  cursor: pointer;
+  display: flex;
+  z-index: 1;
+  align-items: center;
+  justify-content: center;
+  :hover {
+    border-color: #ffffff;
+    background: #ffffff11;
+  }
+
+  > i {
+    font-size: 18px;
+    color: #ffffff;
+  }
+`;
+
+const InviteList = styled.div`
+  display: flex;
+  flex-direction: column;
+  gap: 15px;
+`;
+
+const ScrollableContainer = styled.div`
+  flex: 1;
+  overflow-y: auto;
+  max-height: 480px;
+`;

+ 19 - 0
dashboard/src/lib/invites/types.ts

@@ -0,0 +1,19 @@
+import {z} from "zod";
+
+export const inviteValidator = z.object({
+    id: z.number(),
+    status: z.string(),
+    project: z.object(
+        {
+            id: z.number(),
+            name: z.string(),
+        }
+    ),
+    inviter: z.object(
+        {
+            email: z.string(),
+            company: z.string(),
+        }
+    ),
+});
+export type Invite = z.infer<typeof inviteValidator>;

+ 26 - 4
dashboard/src/main/MainWrapper.tsx

@@ -7,22 +7,44 @@ import MainWrapperErrorBoundary from "shared/error_handling/MainWrapperErrorBoun
 import AuthnProvider from "../shared/auth/AuthnContext";
 import { ContextProvider } from "../shared/Context";
 import Main from "./Main";
-import CurrentError from "./CurrentError";
+
+import {
+    ThemeProvider,
+    IntlProvider,
+} from "@ory/elements"
+
+// required styles for Ory Elements
+import "@ory/elements/dist/style.css"
+
 
 type PropsType = RouteComponentProps & {};
 
 const MainWrapper: React.FC<PropsType> = ({ history, location }) => {
   return (
     <ContextProvider history={history} location={location}>
-      <AuthzProvider>
+        <ThemeProvider themeOverrides={{}}>
+            {/* We dont need to pass any custom translations */}
+            {/* <IntlProvider> */}
+            {/* We pass custom translations */}
+            <IntlProvider
+                locale="en"
+                defaultLocale="en"
+            >
+        <AuthzProvider>
         <AuthnProvider>
-          <MainWrapperErrorBoundary>
+
+
+            <MainWrapperErrorBoundary>
             <Main />
           </MainWrapperErrorBoundary>
         </AuthnProvider>
       </AuthzProvider>
+            </IntlProvider>
+
+        </ThemeProvider>
+
     </ContextProvider>
   );
 };
 
-export default withRouter(MainWrapper);
+export default withRouter(MainWrapper);

+ 48 - 182
dashboard/src/main/auth/Login.tsx

@@ -24,11 +24,6 @@ type Props = {
   authenticate: () => Promise<void>;
 };
 
-const getWindowDimensions = () => {
-  const { innerWidth: width, innerHeight: height } = window;
-  return { width, height };
-};
-
 const Login: React.FC<Props> = ({ authenticate }) => {
   const { setUser, setCurrentError } = useContext(Context);
   const [email, setEmail] = useState("");
@@ -39,9 +34,6 @@ const Login: React.FC<Props> = ({ authenticate }) => {
   const [hasGithub, setHasGithub] = useState(true);
   const [hasGoogle, setHasGoogle] = useState(false);
   const [hasResetPassword, setHasResetPassword] = useState(true);
-  const [windowDimensions, setWindowDimensions] = useState(
-    getWindowDimensions()
-  );
 
   const handleLogin = (): void => {
     if (!emailRegex.test(email)) {
@@ -65,10 +57,6 @@ const Login: React.FC<Props> = ({ authenticate }) => {
     }
   };
 
-  const handleResize = () => {
-    setWindowDimensions(getWindowDimensions());
-  };
-
   const handleKeyDown = (e: any) => {
     if (e.key === "Enter") {
       handleLogin();
@@ -102,10 +90,6 @@ const Login: React.FC<Props> = ({ authenticate }) => {
     const emailFromCLI = urlParams.get("email");
     emailFromCLI && setEmail(emailFromCLI);
 
-    window.addEventListener("resize", handleResize);
-    return () => {
-      window.removeEventListener("resize", handleResize);
-    };
   }, []);
 
   const githubRedirect = () => {
@@ -119,102 +103,69 @@ const Login: React.FC<Props> = ({ authenticate }) => {
   };
 
   return (
-    <StyledLogin>
-      {windowDimensions.width > windowDimensions.height && (
-        <Wrapper>
-          <Container row>
-            <a href="https://porter.run">
-              <Logo src={logo} />
-            </a>
-            {window.location.hostname === "cloud.porter.run" && (
-              <Badge>Cloud</Badge>
-            )}
-          </Container>
-          <Spacer y={2} />
-          <Jumbotron>
-            <Shiny>Welcome back to Porter</Shiny>
-          </Jumbotron>
-          <Spacer y={2} />
-          <LinkRow to="https://porter.run/docs" target="_blank">
-            <img src={docs} /> Read the Porter docs
-          </LinkRow>
-          <Spacer y={0.5} />
-          <LinkRow to="https://porter.run/blog" target="_blank">
-            <img src={blog} /> See what's new with Porter
-          </LinkRow>
-        </Wrapper>
-      )}
-      <Wrapper>
-        {windowDimensions.width <= windowDimensions.height && (
-          <Flex>
-            <a href="https://porter.run">
-              <Logo src={logo} />
-            </a>
-            <Spacer y={2} />
-          </Flex>
-        )}
-        <Heading isAtTop>Log in to your Porter account</Heading>
-        <Spacer y={1} />
-        {(hasGithub || hasGoogle) && (
+    <Container>
+      <Heading isAtTop>Log in to your Porter account</Heading>
+      <Spacer y={1} />
+      {(hasGithub || hasGoogle) && (
           <>
             <Container row>
               {hasGithub && (
-                <OAuthButton onClick={githubRedirect}>
-                  <Icon src={github} />
-                  Log in with GitHub
-                </OAuthButton>
+                  <OAuthButton onClick={githubRedirect}>
+                    <Icon src={github} />
+                    Log in with GitHub
+                  </OAuthButton>
               )}
               {hasGithub && hasGoogle && <Spacer inline x={2} />}
               {hasGoogle && (
-                <OAuthButton onClick={googleRedirect}>
-                  <StyledGoogleIcon />
-                  Log in with Google
-                </OAuthButton>
+                  <OAuthButton onClick={googleRedirect}>
+                    <StyledGoogleIcon />
+                    Log in with Google
+                  </OAuthButton>
               )}
             </Container>
             {hasBasic && (
-              <OrWrapper>
-                <Line />
-                <Or>or</Or>
-              </OrWrapper>
+                <OrWrapper>
+                  <Line />
+                  <Or>or</Or>
+                </OrWrapper>
             )}
           </>
-        )}
-        {hasBasic && (
+      )}
+      {hasBasic && (
           <>
             <Input
-              autoFocus={true}
-              type="email"
-              placeholder="Email"
-              label="Email"
-              value={email}
-              setValue={(x) => {
-                setEmail(x);
-                setEmailError(false);
-                setCredentialError(false);
-              }}
-              width="100%"
-              height="40px"
-              error={emailError && "Please enter a valid email"}
+                autoFocus={true}
+                type="email"
+                placeholder="Email"
+                label="Email"
+                value={email}
+                setValue={(x) => {
+                  setEmail(x);
+                  setEmailError(false);
+                  setCredentialError(false);
+                }}
+                width="100%"
+                height="40px"
+                error={emailError && "Please enter a valid email"}
             />
             <Spacer y={1} />
             <Input
-              type="password"
-              placeholder="Password"
-              label="Password"
-              value={password}
-              setValue={(x) => {
-                setPassword(x);
-                setCredentialError(false);
-              }}
-              width="100%"
-              height="40px"
-              error={credentialError && ""}
+                type="password"
+                placeholder="Password"
+                label="Password"
+                value={password}
+                setValue={(x) => {
+                  setPassword(x);
+                  setCredentialError(false);
+                }}
+                width="100%"
+                height="40px"
+                error={credentialError && ""}
             >
               {hasResetPassword && (
-                <ForgotPassword>
-                  <Link to="/password/reset">Forgot your password?</Link>
-                </ForgotPassword>
+                  <ForgotPassword>
+                    <Link to="/password/reset">Forgot your password?</Link>
+                  </ForgotPassword>
               )}
             </Input>
             <Spacer height="30px" />
@@ -222,29 +173,13 @@ const Login: React.FC<Props> = ({ authenticate }) => {
               Continue
             </Button>
           </>
-        )}
-        <Spacer y={1} />
-        <Text size={13} color="helper">
-          Don't have an account?
-          <Spacer width="5px" inline />
-          <Link to="/register">Sign up</Link>
-        </Text>
-      </Wrapper>
-    </StyledLogin>
+      )}
+    </Container>
   );
 };
 
 export default Login;
 
-const Badge = styled.div`
-  margin-left: 17px;
-  margin-top: -6px;
-  background: ${(props) => props.theme.clickable};
-  padding: 5px 10px;
-  border: 1px solid #aaaabb;
-  border-radius: 5px;
-`;
-
 const ForgotPassword = styled.div`
   position: absolute;
   right: 0;
@@ -252,54 +187,6 @@ const ForgotPassword = styled.div`
   font-size: 13px;
 `;
 
-const Flex = styled.div`
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  flex-direction: column;
-`;
-
-const LinkRow = styled(DynamicLink)`
-  font-size: 14px;
-  display: flex;
-  align-items: center;
-  width: 220px;
-  color: #aaaabb;
-  > i {
-    font-size: 18px;
-    margin-right: 10px;
-    float: left;
-    color: #4797ff;
-  }
-
-  > img {
-    height: 18px;
-    margin-right: 10px;
-  }
-
-  :hover {
-    filter: brightness(2);
-  }
-`;
-
-const Shiny = styled.span`
-  background-image: linear-gradient(225deg, #fff, #7980ff);
-  -webkit-background-clip: text;
-  background-clip: text;
-  -webkit-text-fill-color: transparent;
-`;
-
-const Jumbotron = styled.div`
-  font-size: 32px;
-  font-weight: 500;
-  line-height: 1.5;
-`;
-
-const Logo = styled.img`
-  height: 24px;
-  user-select: none;
-`;
-
 const StyledGoogleIcon = styled(GoogleIcon)`
   width: 38px;
   height: 38px;
@@ -350,25 +237,4 @@ const OAuthButton = styled.div`
   :hover {
     background: #ffffffdd;
   }
-`;
-
-const Wrapper = styled.div`
-  width: 500px;
-  margin-top: -20px;
-  position: relative;
-  padding: 25px;
-  border-radius: 5px;
-  font-size: 13px;
-`;
-
-const StyledLogin = styled.div`
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  width: 100vw;
-  height: 100vh;
-  position: fixed;
-  top: 0;
-  left: 0;
-  background: #111114;
-`;
+`;

+ 184 - 0
dashboard/src/main/auth/LoginWrapper.tsx

@@ -0,0 +1,184 @@
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+
+import DynamicLink from "components/DynamicLink";
+import Heading from "components/form-components/Heading";
+import Button from "components/porter/Button";
+import Container from "components/porter/Container";
+import Input from "components/porter/Input";
+import Link from "components/porter/Link";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { emailRegex } from "shared/regex";
+import blog from "assets/blog.png";
+import community from "assets/community.png";
+import docs from "assets/docs.png";
+import github from "assets/github-icon.png";
+import GoogleIcon from "assets/GoogleIcon";
+import logo from "assets/logo.png";
+import Login from "./Login";
+import OryLogin from "./OryLogin";
+import Helper from "../../components/form-components/Helper";
+import ToggleRow from "../../components/porter/ToggleRow";
+
+type Props = {
+  authenticate: () => Promise<void>;
+};
+
+const getWindowDimensions = () => {
+  const { innerWidth: width, innerHeight: height } = window;
+  return { width, height };
+};
+
+const LoginWrapper: React.FC<Props> = ({ authenticate }) => {
+  const [legacyLogin, setLegacyLogin] = useState(false);
+  const [windowDimensions, setWindowDimensions] = useState(
+      getWindowDimensions()
+  );
+
+  const handleResize = () => {
+    setWindowDimensions(getWindowDimensions());
+  };
+
+  useEffect(() => {
+    window.addEventListener("resize", handleResize);
+    return () => {
+      window.removeEventListener("resize", handleResize);
+    };
+  }, []);
+
+  return (
+    <StyledLogin>
+      {windowDimensions.width > windowDimensions.height && (
+        <Wrapper>
+          <Container row>
+            <a href="https://porter.run">
+              <Logo src={logo} />
+            </a>
+            {window.location.hostname === "cloud.porter.run" && (
+              <Badge>Cloud</Badge>
+            )}
+          </Container>
+          <Spacer y={2} />
+          <Jumbotron>
+            <Shiny>Welcome back to Porter</Shiny>
+          </Jumbotron>
+          <Spacer y={1} />
+          <ToggleRow
+              isToggled={legacyLogin}
+              onToggle={() => {setLegacyLogin(!legacyLogin)}}
+          >
+            <Helper>Legacy log-in flow</Helper>
+          </ToggleRow>
+          <Spacer y={1} />
+          <LinkRow to="https://porter.run/docs" target="_blank">
+            <img src={docs} /> Read the Porter docs
+          </LinkRow>
+          <Spacer y={0.5} />
+          <LinkRow to="https://porter.run/blog" target="_blank">
+            <img src={blog} /> See what's new with Porter
+          </LinkRow>
+        </Wrapper>
+      )}
+      <Wrapper>
+        {windowDimensions.width <= windowDimensions.height && (
+          <Flex>
+            <a href="https://porter.run">
+              <Logo src={logo} />
+            </a>
+            <Spacer y={2} />
+          </Flex>
+        )}
+        <Spacer y={2} />
+        {legacyLogin ? (
+          <Login authenticate={authenticate} />
+        ) : (
+            <OryLogin authenticate={authenticate} />
+        )}
+      </Wrapper>
+    </StyledLogin>
+  );
+};
+
+export default LoginWrapper;
+
+const Badge = styled.div`
+  margin-left: 17px;
+  margin-top: -6px;
+  background: ${(props) => props.theme.clickable};
+  padding: 5px 10px;
+  border: 1px solid #aaaabb;
+  border-radius: 5px;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-direction: column;
+`;
+
+const LinkRow = styled(DynamicLink)`
+  font-size: 14px;
+  display: flex;
+  align-items: center;
+  width: 220px;
+  color: #aaaabb;
+  > i {
+    font-size: 18px;
+    margin-right: 10px;
+    float: left;
+    color: #4797ff;
+  }
+
+  > img {
+    height: 18px;
+    margin-right: 10px;
+  }
+
+  :hover {
+    filter: brightness(2);
+  }
+`;
+
+const Shiny = styled.span`
+  background-image: linear-gradient(225deg, #fff, #7980ff);
+  -webkit-background-clip: text;
+  background-clip: text;
+  -webkit-text-fill-color: transparent;
+`;
+
+const Jumbotron = styled.div`
+  font-size: 32px;
+  font-weight: 500;
+  line-height: 1.5;
+`;
+
+const Logo = styled.img`
+  height: 24px;
+  user-select: none;
+`;
+
+const Wrapper = styled.div`
+  width: 500px;
+  margin-top: -20px;
+  position: relative;
+  padding: 25px;
+  border-radius: 5px;
+  font-size: 13px;
+`;
+
+const StyledLogin = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100vw;
+  height: 100vh;
+  position: fixed;
+  top: 0;
+  left: 0;
+  background: #111114;
+`;

+ 197 - 0
dashboard/src/main/auth/OryLogin.tsx

@@ -0,0 +1,197 @@
+import {Identity, type LoginFlow, type UpdateLoginFlowBody} from "@ory/client"
+import { UserAuthCard } from "@ory/elements"
+import React, {useCallback, useEffect, useState} from "react"
+import { useHistory } from "react-router-dom"
+import { useLocation } from "react-router";
+import {basePath, sdk, sdkError} from "shared/auth/sdk"
+import api from "../../shared/api";
+import styled from "styled-components";
+import Loading from "../../components/Loading";
+
+/**
+ * Login is a React component that renders the login form using Ory Elements.
+ * It is used to handle the login flow for a variety of authentication methods
+ * and authentication levels (e.g. Single-Factor and Two-Factor)
+ *
+ * The Login component also handles the OAuth2 login flow (as an OAuth2 provider)
+ * For more information regarding OAuth2 login, please see the following documentation:
+ * https://www.ory.sh/docs/oauth2-oidc/custom-login-consent/flow
+ *
+ */
+type Props = {
+    authenticate: () => Promise<void>;
+};
+
+const OryLogin: React.FC<Props> = ({ authenticate }): JSX.Element => {
+    const [flow, setFlow] = useState<LoginFlow | null>(null)
+
+    const { search } = useLocation();
+    const queryParams = new URLSearchParams(search);
+
+    // The aal is set as a query parameter by your Ory project
+    // aal1 is the default authentication level (Single-Factor)
+    // aal2 is a query parameter that can be used to request Two-Factor authentication
+    // https://www.ory.sh/docs/kratos/mfa/overview
+    const aal2 = queryParams.get("aal2")
+
+    // The login_challenge is a query parameter set by the Ory OAuth2 login flow
+    // Switching between pages should keep the login_challenge in the URL so that the
+    // OAuth flow can be completed upon completion of another flow (e.g. Registration).
+    // https://www.ory.sh/docs/oauth2-oidc/custom-login-consent/flow
+    const loginChallenge = queryParams.get("login_challenge")
+
+    // The return_to is a query parameter is set by you when you would like to redirect
+    // the user back to a specific URL after login is successful
+    // In most cases it is not necessary to set a return_to if the UI business logic is
+    // handled by the SPA.
+    //
+    // In OAuth flows this value might be ignored in favor of keeping the OAuth flow
+    // intact between multiple flows (e.g. Login -> Recovery -> Settings -> OAuth2 Consent)
+    // https://www.ory.sh/docs/oauth2-oidc/identity-provider-integration-settings
+    const returnTo = queryParams.get("return_to")
+
+    const history = useHistory()
+
+    // Get the flow based on the flowId in the URL (.e.g redirect to this page after flow initialized)
+    const getFlow = useCallback(
+        (flowId: string) =>
+            sdk
+                // the flow data contains the form fields, error messages and csrf token
+                .getLoginFlow({ id: flowId })
+                .then(({ data: flow }) => setFlow(flow))
+                .catch(sdkErrorHandler),
+        [],
+    )
+
+    // initialize the sdkError for generic handling of errors
+    const sdkErrorHandler = sdkError(getFlow, setFlow, "/login", true)
+
+    // Create a new login flow
+    const createFlow = () => {
+        sdk
+            .createBrowserLoginFlow({
+                refresh: true,
+                aal: aal2 ? "aal2" : "aal1",
+                ...(loginChallenge && { loginChallenge: loginChallenge }),
+                ...(returnTo && { returnTo: returnTo }),
+            })
+            // flow contains the form fields and csrf token
+            .then(({ data: flow }) => {
+                // Update URI query params to include flow id
+                queryParams.set("flow", flow.id)
+                // Set the flow data
+                setFlow(flow)
+            })
+            .catch(sdkErrorHandler)
+    }
+
+    const registerPorterUser = (identity: Identity): void => {
+        api
+            .registerUser("", {
+                email: identity.traits?.email || "",
+                password: "",
+                first_name: identity.traits?.name || "",
+                last_name: "",
+                company_name: "",
+                referral_method: "",
+                auth_provider: "ory",
+                external_id: identity.id || "",
+            }, {})
+            .then(() => {
+                // add user to context here
+                console.log("User registered")
+                authenticate().catch(() => {});
+
+            })
+            .catch((err) => {
+                console.error(err)
+            })
+    }
+
+    // submit the login form data to Ory
+    const submitFlow = (body: UpdateLoginFlowBody): void => {
+        // something unexpected went wrong and the flow was not set
+        if (!flow) {
+            history.push("/login", { replace: true })
+            return
+        }
+
+        // we submit the flow to Ory with the form data
+        sdk
+            .updateLoginFlow({ flow: flow.id, updateLoginFlowBody: body })
+            .then((resp) => {
+
+                if (resp?.data?.session?.identity) {
+                    registerPorterUser(resp.data.session.identity)
+                }
+                // we successfully submitted the login flow, so reauthenticate and lets redirect to the dashboard
+                history.push("/", { replace: true })
+            })
+            .catch(sdkErrorHandler)
+
+    }
+
+    useEffect(() => {
+        // we might redirect to this page after the flow is initialized, so we check for the flowId in the URL
+        const flowId = queryParams.get("flow")
+        // the flow already exists
+        if (flowId) {
+            getFlow(flowId).catch(createFlow) // if for some reason the flow has expired, we need to get a new one
+            return
+        }
+
+        // we assume there was no flow, so we create a new one
+        createFlow()
+    }, [])
+
+    // we check if the flow is set, if not we show a loading indicator
+    return flow ? (
+        // we render the login form using Ory Elements
+        <Scrollable>
+
+        <UserAuthCard
+            title={""}
+            flowType={"login"}
+            // we always need the flow data which populates the form fields and error messages dynamically
+            flow={flow}
+            // the login card should allow the user to go to the registration page and the recovery page
+            additionalProps={{
+                forgotPasswordURL: {
+                    handler: () => {
+                        const search = new URLSearchParams()
+                        flow.return_to && search.set("return_to", flow.return_to)
+                        window.location.replace(`${basePath}/ui/recovery?${search.toString()}`)
+                    },
+                },
+                signupURL: {
+                    handler: () => {
+                        const search = new URLSearchParams()
+                        flow.return_to && search.set("return_to", flow.return_to)
+                        flow.oauth2_login_challenge &&
+                        search.set("login_challenge", flow.oauth2_login_challenge)
+                        window.location.replace(`${basePath}/ui/registration?${search.toString()}`)
+                    },
+                },
+            }}
+            // we might need webauthn support which requires additional js
+            includeScripts={true}
+            // we submit the form data to Ory
+            onSubmit={({ body }) => submitFlow(body as UpdateLoginFlowBody)}
+        />
+        </Scrollable>
+    ) : (
+        <Loading/>
+    )
+}
+
+
+
+export default OryLogin;
+
+
+
+            const Scrollable = styled.div`
+            height: 80vh;
+            width: 80vh;
+            overflow: auto;
+            `;

+ 39 - 3
dashboard/src/main/home/Home.tsx

@@ -69,6 +69,8 @@ import { NewProjectFC } from "./new-project/NewProject";
 import Onboarding from "./onboarding/Onboarding";
 import ProjectSettings from "./project-settings/ProjectSettings";
 import Sidebar from "./sidebar/Sidebar";
+import {AuthnContext, useAuthn} from "../../shared/auth/AuthnContext";
+import UserInviteModal from "../../components/UserInviteModal";
 
 dayjs.extend(relativeTime);
 
@@ -125,6 +127,22 @@ const Home: React.FC<Props> = (props) => {
   const [forceSidebar, setForceSidebar] = useState(true);
   const [theme, setTheme] = useState(standard);
   const [showWrongEmailModal, setShowWrongEmailModal] = useState(false);
+  const [inviteModalOpen, setInviteModalOpen] = useState(false);
+
+  const { invites, invitesLoading } = useAuthn();
+
+  console.log(currentProject, projects, user)
+
+  useEffect(() => {
+    console.log(invites.length, invitesLoading, inviteModalOpen)
+    if (invites.length && !invitesLoading) {
+      setInviteModalOpen(true);
+    } else {
+      setInviteModalOpen(false);
+    }
+  }, [invites.length, invitesLoading]);
+
+  console.log(invites)
 
   const redirectToNewProject = () => {
     pushFiltered(props, "/new-project", ["project_id"]);
@@ -146,6 +164,7 @@ const Home: React.FC<Props> = (props) => {
   };
 
   const getProjects = async (id?: number) => {
+    console.log("getProjects")
     const { currentProject } = props;
     const queryString = window.location.search;
     const urlParams = new URLSearchParams(queryString);
@@ -159,11 +178,13 @@ const Home: React.FC<Props> = (props) => {
         .getProjects("<token>", {}, { id: user.userId })
         .then((res) => res.data as ProjectListType[]);
 
+      setProjects(projectList);
+
+      console.log(projectList)
+
       if (projectList.length === 0) {
         redirectToNewProject();
       } else if (projectList.length > 0 && !currentProject) {
-        setProjects(projectList);
-
         if (!id) {
           id =
             Number(localStorage.getItem("currentProject")) || projectList[0].id;
@@ -224,6 +245,15 @@ const Home: React.FC<Props> = (props) => {
     } catch (error) {}
   };
 
+  useEffect(() => {
+      // Handle redirect from DO
+      const queryString = window.location.search;
+      const urlParams = new URLSearchParams(queryString);
+
+      const defaultProjectId = parseInt(urlParams.get("project_id"));
+      getProjects(defaultProjectId);
+  }, [invites])
+
   useEffect(() => {
     checkOnboarding();
     checkIfCanCreateProject();
@@ -258,7 +288,7 @@ const Home: React.FC<Props> = (props) => {
     return () => {
       setCanCreateProject(false);
     };
-  }, []);
+  }, [user]);
 
   // Hacky legacy shim for remote cluster refresh until Context is properly split
   useEffect(() => {
@@ -669,6 +699,12 @@ const Home: React.FC<Props> = (props) => {
               <Button onClick={props.logOut}>Log out</Button>
             </Modal>
           )}
+          {inviteModalOpen && (
+              <UserInviteModal invites={invites} closeModal={() => {
+                setInviteModalOpen(false)
+                props.history.push("/")
+              }}/>
+          )}
         </StyledHome>
       </DeploymentTargetProvider>
     </ThemeProvider>

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

@@ -48,6 +48,8 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
   const [isHTTPS] = useState(() => window.location.protocol === "https:");
   const [showNewGroupModal, setShowNewGroupModal] = useState(false);
 
+  console.log(invites);
+
   useEffect(() => {
     api
       .getAvailableRoles("<token>", {}, { project_id: currentProject?.id })
@@ -79,7 +81,7 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
         }
       );
       invites = response.data.filter(
-        (i: InviteType) => !i.accepted && !i.email.includes("@porter.run")
+        (i: InviteType) => !i.accepted && (!i.email.includes("@porter.run") || user.isPorterUser)
       );
     } catch (err) {
       console.log(err);
@@ -97,6 +99,7 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
     } catch (err) {
       console.log(err);
     }
+    console.log(collaborators)
     setInvites([...invites, ...collaborators]);
     setIsLoading(false);
   };
@@ -104,14 +107,15 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
   const parseCollaboratorsResponse = (
     collaborators: Collaborator[]
   ): InviteType[] => {
+      console.log(collaborators)
     const admins = collaborators
-      .filter((c) => c.kind === "admin" && !c.email.includes("@porter.run"))
+      .filter((c) => c.kind === "admin" && (!c.email.includes("@porter.run") || user?.isPorterUser))
       .map((c) => ({ ...c, id: Number(c.id) }))
       .sort((curr, prev) => curr.id - prev.id)
       .slice(1);
 
     const nonAdmins = collaborators
-      .filter((c) => c.kind !== "admin" && !c.email.includes("@porter.run"))
+      .filter((c) => c.kind !== "admin" && (!c.email.includes("@porter.run") || user?.isPorterUser))
       .map((c) => ({ ...c, id: Number(c.id) }))
       .sort((curr, prev) => curr.id - prev.id);
 

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

@@ -543,6 +543,20 @@ const createGCPIntegration = baseApi<
   return `/api/projects/${pathParams.project_id}/integrations/gcp`;
 });
 
+const listUserInvites = baseApi<{}, { id: number }>("GET", () => {
+    return `/api/users/invites`;
+});
+
+const respondUserInvites = baseApi<
+    {
+    accepted_invite_ids: number[];
+    declined_invite_ids: number[];
+    },
+    {}
+>("POST", () => {
+    return `/api/users/invites/response`;
+});
+
 const createInvite = baseApi<
   {
     email: string;
@@ -2157,6 +2171,8 @@ const registerUser = baseApi<{
   company_name: string;
   referral_method?: string;
   referred_by_code?: string;
+  auth_provider?: string;
+  external_id?: string;
 }>("POST", "/api/users");
 
 const rollbackChart = baseApi<
@@ -3998,4 +4014,8 @@ export default {
 
   // system status
   systemStatusHistory,
+
+
+  listUserInvites,
+  respondUserInvites,
 };

+ 144 - 42
dashboard/src/shared/auth/AuthnContext.tsx

@@ -1,9 +1,14 @@
-import React, { useContext, useEffect, useState } from "react";
+import React, { useContext, useEffect, useMemo, useState } from "react";
 import { Redirect, Route, Switch } from "react-router-dom";
 
 import api from "shared/api";
 import { Context } from "shared/Context";
 
+import { Configuration, FrontendApi, Session, Identity } from "@ory/client"
+import {useQuery} from "@tanstack/react-query";
+import {clusterStateValidator} from "../../lib/clusters/types";
+import {Invite, inviteValidator} from "../../lib/invites/types";
+import {z} from "zod";
 import Loading from "../../components/Loading";
 import Login from "../../main/auth/Login";
 import Register from "../../main/auth/Register";
@@ -12,10 +17,29 @@ import ResetPasswordInit from "../../main/auth/ResetPasswordInit";
 import SetInfo from "../../main/auth/SetInfo";
 import VerifyEmail from "../../main/auth/VerifyEmail";
 import CurrentError from "../../main/CurrentError";
+import LoginWrapper from "../../main/auth/LoginWrapper";
+
+// Get your Ory url from .env
+// Or localhost for local development
+const basePath = process.env.REACT_APP_ORY_URL || "http://localhost:4000"
+const ory = new FrontendApi(
+    new Configuration({
+        basePath,
+        baseOptions: {
+            withCredentials: true,
+        },
+    }),
+)
+
 
 type AuthnState = {
   userId: number;
+  authenticate: () => Promise<void>;
   handleLogOut: () => void;
+    session: Session | null;
+    invites: Invite[];
+    checkInvites: () => void;
+    invitesLoading: boolean;
 };
 
 export const AuthnContext = React.createContext<AuthnState | null>(null);
@@ -35,57 +59,130 @@ const AuthnProvider = ({
 }): JSX.Element => {
   const { setUser, clearContext, setCurrentError, currentError } =
     useContext(Context);
-  const [isLoggedIn, setIsLoggedIn] = useState(false);
+    const [isPorterAuthenticated, setIsPorterAuthenticated] = useState(false);
+
+    const [isLoggedIn, setIsLoggedIn] = useState(false);
   const [isEmailVerified, setIsEmailVerified] = useState(false);
   const [isLoading, setIsLoading] = useState(true);
+  const [invitesLoading, setInvitesLoading] = useState(true);
   const [userId, setUserId] = useState(-1);
   const [hasInfo, setHasInfo] = useState(false);
+  const [session, setSession] = useState<Session | null>(null)
+  const [logoutUrl, setLogoutUrl] = useState<string | undefined>()
+  const [invites, setInvites] = useState<Invite[]>([])
   const [local, setLocal] = useState(false);
 
+    console.log(invites)
+
   const authenticate = async (): Promise<void> => {
-    api
-      .checkAuth("", {}, {})
-      .then((res) => {
-        if (res?.data) {
-          setUser?.(res.data.id, res.data.email);
-          setIsLoggedIn(true);
-          setIsEmailVerified(res.data.email_verified);
-          setHasInfo(res.data.company_name && true);
-          setIsLoading(false);
-          setUserId(res.data.id);
-        } else {
-          setIsLoggedIn(false);
-          setIsEmailVerified(false);
-          setHasInfo(false);
-          setIsLoading(false);
-          setUserId(-1);
-        }
-      })
-      .catch(() => {
-        setIsLoggedIn(false);
-        setIsEmailVerified(false);
-        setHasInfo(false);
-        setIsLoading(false);
-        setUserId(-1);
-      });
-  };
+              ory
+                  .toSession()
+                  .then(({data}) => {
+
+                      // Create a logout url
+                      ory.createBrowserLogoutFlow().then(({data}) => {
+                          setLogoutUrl(data.logout_url)
+                      })
+
+                      if (!(data?.identity?.verifiable_addresses?.some(address => address.value === data?.identity?.traits?.email && address.verified) || false)) {
+                            window.location.replace(`${basePath}/ui/verification`)
+                      }
+
+                      // User has a session!
+                      setSession(data)
+                      console.log(data)
+                      console.log(data?.identity?.verifiable_addresses)
+                      console.log(data?.identity?.traits)
+                      console.log(data?.identity?.verifiable_addresses?.some(address => address.value === data?.identity?.traits?.email && address.verified) || false)
+                      setIsEmailVerified(data?.identity?.verifiable_addresses?.some(address => address.value === data?.identity?.traits?.email && address.verified) || false)
+                  }).catch((err) => {
+                    setSession(null)
+                    console.log(err)
+              })
+                  .then(() => {
+                      api
+                  .checkAuth("", {}, {})
+                  .then((res) => {
+                      if (res?.data) {
+                          setUser?.(res.data.id, res.data.email);
+                          setIsEmailVerified(res.data.email_verified);
+                          setIsPorterAuthenticated(true);
+                          setIsEmailVerified(res.data.email_verified);
+                          setHasInfo(res.data.company_name && true);
+                          setUserId(res.data.id);
+                      } else {
+                          setIsPorterAuthenticated(false);
+                      }
+                  })
+                  .catch(() => {
+                      setIsPorterAuthenticated(false);
+                  })
+
+          })
+          .catch((err) => {
+              console.error(err)
+          })
+          .finally(() => {
+            setIsLoading(false);
+        });
+    };
 
   const handleLogOut = (): void => {
     // Clears local storage for proper rendering of clusters
     // Attempt user logout
-    api
-      .logOutUser("<token>", {}, {})
-      .then(() => {
-        setIsLoggedIn(false);
-        setIsEmailVerified(false);
-        clearContext?.();
-        localStorage.clear();
-      })
-      .catch((err) => {
-        setCurrentError?.(err.response?.data.errors[0]);
-      });
+   if (isPorterAuthenticated) {
+       api
+           .logOutUser("<token>", {}, {})
+           .then(() => {
+               setIsLoggedIn(false);
+               setIsPorterAuthenticated(false);
+               setIsEmailVerified(false);
+               clearContext();
+               localStorage.clear();
+           }).catch((err) => {
+               setCurrentError(err.response?.data.errors[0]);
+           })
+   }
+
+   if (session && logoutUrl) {
+       window.location.replace(logoutUrl)
+   }
   };
 
+    useEffect(() => {
+        setIsLoggedIn(isPorterAuthenticated || !!session)
+    }, [isPorterAuthenticated, session])
+
+    useEffect(() => {
+        checkInvites()
+    }, [userId])
+
+    const checkInvites =  () => {
+        setInvitesLoading(true)
+        api.listUserInvites(
+            "<token>",
+            {},
+            {}
+        )
+            .then((res) => {
+                const parsed = z.array(inviteValidator).safeParse(res.data)
+                if (parsed.success) {
+                    console.log(parsed.data)
+                    setInvites(parsed.data)
+                } else {
+                    setInvites([])
+                }
+            })
+            .catch(() => {
+                setInvites([]);
+            }).finally(() => {
+            setInvitesLoading(false)
+        })
+    }
+
+
+    console.log(invites)
+
   useEffect(() => {
     authenticate().catch(() => {});
 
@@ -110,7 +207,7 @@ const AuthnProvider = ({
           <Route
             path="/login"
             render={() => {
-              return <Login authenticate={authenticate} />;
+              return <LoginWrapper authenticate={authenticate} />;
             }}
           />
           <Route
@@ -185,8 +282,13 @@ const AuthnProvider = ({
   return (
     <AuthnContext.Provider
       value={{
-        userId,
-        handleLogOut,
+          userId,
+          authenticate,
+          handleLogOut,
+          session,
+          invites,
+          checkInvites,
+          invitesLoading
       }}
     >
       {children}

+ 178 - 0
dashboard/src/shared/auth/sdk.ts

@@ -0,0 +1,178 @@
+
+import { Configuration, FrontendApi } from "@ory/client"
+import { AxiosError } from "axios"
+import React, { useCallback } from "react"
+import { useHistory } from "react-router-dom"
+
+export const basePath = process.env.REACT_APP_ORY_URL || "http://localhost:4000"
+
+export const sdk = new FrontendApi(
+    new Configuration({
+        basePath:  basePath,
+        // we always want to include the cookies in each request
+        // cookies are used for sessions and CSRF protection
+        baseOptions: {
+            withCredentials: true,
+        },
+    }),
+)
+
+/**
+ * @param getFlow - Should be function to load a flow make it visible (Login.getFlow)
+ * @param setFlow - Update flow data to view (Login.setFlow)
+ * @param defaultNav - Default navigate target for errors
+ * @param fatalToDash - When true and error can not be handled, then redirect to dashboard, else rethrow error
+ */
+export const sdkError = (
+    getFlow: ((flowId: string) => Promise<void | AxiosError>) | undefined,
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    setFlow: React.Dispatch<React.SetStateAction<any>> | undefined,
+    defaultNav: string | undefined,
+    fatalToDash = false,
+) => {
+    const history = useHistory()
+
+    return useCallback(
+        (error: AxiosError<any, unknown>): Promise<AxiosError | void> => {
+            const responseData = error.response?.data || {}
+
+            switch (error.response?.status) {
+                case 400: {
+                    if (error.response.data?.error?.id === "session_already_available") {
+                        console.warn(
+                            "sdkError 400: `session_already_available`. Navigate to /",
+                        )
+                        history.push("/")
+                        return Promise.resolve()
+                    }
+                    // the request could contain invalid parameters which would set error messages in the flow
+                    if (setFlow !== undefined) {
+                        console.warn("sdkError 400: update flow data")
+                        setFlow(responseData)
+                        return Promise.resolve()
+                    }
+                    break
+                }
+                case 401: {
+                    console.warn("sdkError 401: Navigate to /login")
+                    history.push("/login")
+                    return Promise.resolve()
+                }
+                case 403: {
+                    // the user might have a session, but would require 2FA (Two-Factor Authentication)
+                    if (responseData.error?.id === "session_aal2_required") {
+                        history.push("/login?aal2=true")
+                        return Promise.resolve()
+                    }
+
+                    if (
+                        responseData.error?.id === "session_refresh_required" &&
+                        responseData.redirect_browser_to
+                    ) {
+                        console.warn("sdkError 403: Redirect browser to")
+                        window.location = responseData.redirect_browser_to
+                        return Promise.resolve()
+                    }
+                    break
+                }
+                case 404: {
+                    if (defaultNav !== undefined) {
+                        console.warn("sdkError 404: Navigate to Error")
+                        const errorMsg = {
+                            data: error.response?.data || error,
+                            status: error.response?.status,
+                            statusText: error.response?.statusText,
+                            url: window.location.href,
+                        }
+
+                        history.push(
+                            `/error?error=${encodeURIComponent(JSON.stringify(errorMsg))}`,
+                            {
+                                replace: true,
+                            },
+                        )
+                        return Promise.resolve()
+                    }
+                    break
+                }
+                case 410: {
+                    if (getFlow !== undefined && responseData.use_flow_id !== undefined) {
+                        console.warn("sdkError 410: Update flow")
+                        return getFlow(responseData.use_flow_id).catch((error) => {
+                            // Something went seriously wrong - log and redirect to defaultNav if possible
+                            console.error(error)
+
+                            if (defaultNav !== undefined) {
+                                history.push(defaultNav)
+                            } else {
+                                // Rethrow error when can't navigate and let caller handle
+                                throw error
+                            }
+                        })
+                    } else if (defaultNav !== undefined) {
+                        console.warn("sdkError 410: Navigate to", defaultNav)
+                        history.push(defaultNav)
+                        return Promise.resolve()
+                    }
+                    break
+                }
+                case 422: {
+                    if (responseData.redirect_browser_to !== undefined) {
+                        const currentUrl = new URL(window.location.href)
+                        const redirect = new URL(
+                            responseData.redirect_browser_to,
+                            // need to add the base url since the `redirect_browser_to` is a relative url with no hostname
+                            window.location.origin,
+                        )
+
+                        // Path has changed
+                        if (currentUrl.pathname !== redirect.pathname) {
+                            console.warn("sdkError 422: Update path")
+                            // remove /ui prefix from the path in case it is present (not setup correctly inside the project config)
+                            // since this is an SPA we don't need to redirect to the Account Experience.
+                            redirect.pathname = redirect.pathname.replace("/ui", "")
+                            navigate(redirect.pathname + redirect.search, {
+                                replace: true,
+                            })
+                            return Promise.resolve()
+                        }
+
+                        // for webauthn we need to reload the flow
+                        const flowId = redirect.searchParams.get("flow")
+
+                        if (flowId != null && getFlow !== undefined) {
+                            // get new flow data based on the flow id in the redirect url
+                            console.warn("sdkError 422: Update flow")
+                            return getFlow(flowId).catch((error) => {
+                                // Something went seriously wrong - log and redirect to defaultNav if possible
+                                console.error(error)
+
+                                if (defaultNav !== undefined) {
+                                    navigate(defaultNav, { replace: true })
+                                } else {
+                                    // Rethrow error when can't navigate and let caller handle
+                                    throw error
+                                }
+                            })
+                        } else {
+                            console.warn("sdkError 422: Redirect browser to")
+                            window.location = responseData.redirect_browser_to
+                            return Promise.resolve()
+                        }
+                    }
+                }
+            }
+
+            console.error(error)
+
+            if (fatalToDash) {
+                console.warn("sdkError: fatal error redirect to dashboard")
+                history.push("/")
+                return Promise.resolve()
+            }
+
+            throw error
+        },
+        [history, getFlow],
+    )
+}

+ 14 - 1
dashboard/webpack.config.js

@@ -92,9 +92,22 @@ module.exports = () => {
           include: /node_modules/,
           type: "javascript/auto",
         },
+        {
+          test: /\.mjs$/,
+          use: [
+            {
+              loader: require.resolve("babel-loader"),
+              options: {
+                plugins: [
+                  isDevelopment && require.resolve("react-refresh/babel"),
+                ].filter(Boolean),
+              },
+            },
+          ],
+        },
         {
           enforce: "pre",
-          test: /\.js$/,
+          test: /\.(ts|tsx|mjs|js|jsx)$/,
           loader: "source-map-loader",
         },
         {

+ 11 - 9
go.mod

@@ -3,7 +3,7 @@ module github.com/porter-dev/porter
 go 1.20
 
 require (
-	cloud.google.com/go v0.110.0 // indirect
+	cloud.google.com/go v0.110.2 // indirect
 	github.com/AlecAivazis/survey/v2 v2.2.9
 	github.com/Masterminds/semver/v3 v3.2.0
 	github.com/aws/aws-sdk-go v1.44.160
@@ -45,11 +45,11 @@ require (
 	github.com/spf13/cobra v1.6.1
 	github.com/spf13/pflag v1.0.5
 	github.com/spf13/viper v1.10.0
-	github.com/stretchr/testify v1.8.4
-	golang.org/x/crypto v0.19.0
-	golang.org/x/net v0.21.0
-	golang.org/x/oauth2 v0.8.0
-	google.golang.org/api v0.114.0
+	github.com/stretchr/testify v1.9.0
+	golang.org/x/crypto v0.21.0
+	golang.org/x/net v0.22.0
+	golang.org/x/oauth2 v0.18.0
+	google.golang.org/api v0.126.0
 	google.golang.org/genproto v0.0.0-20230530153820-e85fd2cbaebc
 	google.golang.org/grpc v1.57.0
 	google.golang.org/protobuf v1.33.0
@@ -86,6 +86,7 @@ require (
 	github.com/matryer/is v1.4.0
 	github.com/nats-io/nats.go v1.24.0
 	github.com/open-policy-agent/opa v0.44.0
+	github.com/ory/client-go v1.9.0
 	github.com/porter-dev/api-contracts v0.2.157
 	github.com/riandyrn/otelchi v0.5.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
@@ -104,7 +105,7 @@ require (
 )
 
 require (
-	cloud.google.com/go/compute v1.19.1 // indirect
+	cloud.google.com/go/compute v1.20.1 // indirect
 	cloud.google.com/go/compute/metadata v0.2.3 // indirect
 	cloud.google.com/go/longrunning v0.4.1 // indirect
 	github.com/Azure/azure-sdk-for-go v65.0.0+incompatible // indirect
@@ -149,6 +150,7 @@ require (
 	github.com/goccy/go-json v0.10.2 // indirect
 	github.com/golang-jwt/jwt v3.2.1+incompatible // indirect
 	github.com/google/gnostic v0.6.9 // indirect
+	github.com/google/s2a-go v0.1.4 // indirect
 	github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect
 	github.com/gosimple/unidecode v1.0.1 // indirect
 	github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect
@@ -239,7 +241,7 @@ require (
 	github.com/containerd/containerd v1.6.8 // indirect
 	github.com/containerd/stargz-snapshotter/estargz v0.11.4 // indirect
 	github.com/cyphar/filepath-securejoin v0.2.3 // indirect
-	github.com/davecgh/go-spew v1.1.1 // indirect
+	github.com/davecgh/go-spew v1.1.1
 	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
 	github.com/docker/go-metrics v0.0.1 // indirect
 	github.com/docker/go-units v0.4.0 // indirect
@@ -268,7 +270,7 @@ require (
 	github.com/google/gofuzz v1.2.0 // indirect
 	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
 	github.com/google/uuid v1.3.0
-	github.com/googleapis/gax-go/v2 v2.7.1 // indirect
+	github.com/googleapis/gax-go/v2 v2.11.0 // indirect
 	github.com/gorilla/mux v1.8.0 // indirect
 	github.com/gosuri/uitable v0.0.4 // indirect
 	github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect

+ 29 - 17
go.sum

@@ -27,8 +27,8 @@ cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSU
 cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY=
 cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ=
 cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
-cloud.google.com/go v0.110.0 h1:Zc8gqp3+a9/Eyph2KDmcGaPtbKRIoqq4YTlL4NMD0Ys=
-cloud.google.com/go v0.110.0/go.mod h1:SJnCLqQ0FCFGSZMUNUf84MV3Aia54kn7pi8st7tMzaY=
+cloud.google.com/go v0.110.2 h1:sdFPBr6xG9/wkBbfhmUz/JmZC7X6LavQgcrVINrKiVA=
+cloud.google.com/go v0.110.2/go.mod h1:k04UEeEtb6ZBRTv3dZz4CeJC3jKGxyhl0sAiVVquxiw=
 cloud.google.com/go/artifactregistry v1.13.0 h1:o1Q80vqEB6Qp8WLEH3b8FBLNUCrGQ4k5RFj0sn/sgO8=
 cloud.google.com/go/artifactregistry v1.13.0/go.mod h1:uy/LNfoOIivepGhooAUpL1i30Hgee3Cu0l4VTWHUC08=
 cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
@@ -37,8 +37,8 @@ cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvf
 cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
 cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
 cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
-cloud.google.com/go/compute v1.19.1 h1:am86mquDUgjGNWxiGn+5PGLbmgiWXlE/yNWpIpNvuXY=
-cloud.google.com/go/compute v1.19.1/go.mod h1:6ylj3a05WF8leseCdIf77NK0g1ey+nj5IKd5/kvShxE=
+cloud.google.com/go/compute v1.20.1 h1:6aKEtlUiwEpJzM001l0yFkpXmUVXaN8W+fbkb2AZNbg=
+cloud.google.com/go/compute v1.20.1/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM=
 cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
 cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
 cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
@@ -379,7 +379,11 @@ github.com/cloudflare/cloudflare-go v0.76.0/go.mod h1:5ocQT9qQ99QsT1Ii2751490Z5J
 github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
 github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
 github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
+github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
 github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
+github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
+github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
+github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
 github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
 github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
 github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8=
@@ -595,6 +599,7 @@ github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5y
 github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
 github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
 github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
+github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
 github.com/envoyproxy/protoc-gen-validate v0.0.14/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
 github.com/esimonov/ifshort v1.0.3/go.mod h1:yZqNJUrNn20K8Q9n2CrjTKYyVEmX209Hgu+M1LBpeZE=
@@ -898,6 +903,8 @@ github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLe
 github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
+github.com/google/s2a-go v0.1.4 h1:1kZ/sQM3srePvKs3tXAvQzo66XfcReoqFpIpIccE7Oc=
+github.com/google/s2a-go v0.1.4/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A=
 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
 github.com/google/trillian v1.3.11/go.mod h1:0tPraVHrSDkA3BO6vKX67zgLXs6SsOAbHEivX+9mPgw=
@@ -913,8 +920,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5
 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
 github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
 github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
-github.com/googleapis/gax-go/v2 v2.7.1 h1:gF4c0zjUP2H/s/hEGyLA3I0fA2ZWjzYiONAD6cvPr8A=
-github.com/googleapis/gax-go/v2 v2.7.1/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38/qKbhSAKP6QI=
+github.com/googleapis/gax-go/v2 v2.11.0 h1:9V9PWXEsWnPpQhu/PeQIkS4eGzMlTLGgt80cUUI8Ki4=
+github.com/googleapis/gax-go/v2 v2.11.0/go.mod h1:DxmR61SGKkGLa2xigwuZIQpkCI2S5iydzRfb3peWZJI=
 github.com/googleapis/gnostic v0.4.1/go.mod h1:LRhVm6pbyptWbWbuZ38d1eyptfvIytN3ir6b65WBswg=
 github.com/googleapis/gnostic v0.5.1/go.mod h1:6U4PtQXGIEt/Z3h5MAT7FNofLnw9vXk2cUuW7uA/OeU=
 github.com/googleapis/gnostic v0.5.5/go.mod h1:7+EbHbldMins07ALC74bsA81Ovc97DwqyJO1AENw9kA=
@@ -1519,6 +1526,8 @@ github.com/opencontainers/selinux v1.10.0/go.mod h1:2i0OySw99QjzBBQByd1Gr9gSjvuh
 github.com/opencontainers/selinux v1.10.1 h1:09LIPVRP3uuZGQvgR+SgMSNBd1Eb3vlRbGqQpoHsF8w=
 github.com/opencontainers/selinux v1.10.1/go.mod h1:2i0OySw99QjzBBQByd1Gr9gSjvuho1lHsJxIJ3gGbJI=
 github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
+github.com/ory/client-go v1.9.0 h1:wimTWH+jPU51N3AU+xNbX7Y39XtaQpgzjWcUpR+xllc=
+github.com/ory/client-go v1.9.0/go.mod h1:2rAuzjcllpLalawioQssX/c/J1w3Idi47s2bWTtWVoA=
 github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw=
 github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE=
 github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs=
@@ -1764,8 +1773,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
 github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
-github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
 github.com/stretchr/testify v0.0.0-20170130113145-4d4bfba8f1d1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v0.0.0-20180303142811-b89eecf5ca5d/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.1.4/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
@@ -1781,8 +1790,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
 github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
-github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/stripe/stripe-go/v76 v76.21.0 h1:O3GHImHS4oUI3qWMOClHN3zAQF5/oswS/NB7leV1fsU=
 github.com/stripe/stripe-go/v76 v76.21.0/go.mod h1:rw1MxjlAKKcZ+3FOXgTHgwiOa2ya6CPq6ykpJ0Q6Po4=
 github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
@@ -2029,10 +2038,11 @@ golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5y
 golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
-golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
-golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
+golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
+golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -2141,8 +2151,8 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
 golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
-golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
-golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
+golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
+golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -2159,8 +2169,8 @@ golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ
 golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.8.0 h1:6dkIjl3j3LtZ/O3sTgZTMsLKSftL/B8Zgq4huOIIUu8=
-golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
+golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI=
+golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -2329,6 +2339,7 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
@@ -2493,8 +2504,8 @@ google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNe
 google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU=
 google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k=
 google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
-google.golang.org/api v0.114.0 h1:1xQPji6cO2E2vLiI+C/XiFAnsn1WV3mjaEwGLhi3grE=
-google.golang.org/api v0.114.0/go.mod h1:ifYI2ZsFK6/uGddGfAD5BMxlnkBqCmqHSDUVi45N5Yg=
+google.golang.org/api v0.126.0 h1:q4GJq+cAdMAC7XP7njvQ4tvohGLiSlytuL4BQxbIZ+o=
+google.golang.org/api v0.126.0/go.mod h1:mBwVAtz+87bEN6CbA1GtZPDOqY2R5ONPqJeIlvyo4Aw=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -2606,6 +2617,7 @@ google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQ
 google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
 google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
 google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
+google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
 google.golang.org/grpc v1.57.0 h1:kfzNeI/klCGD2YPMUlaGNT3pxvYfga7smW3Vth8Zsiw=
 google.golang.org/grpc v1.57.0/go.mod h1:Sd+9RMTACXwmub0zcNY2c4arhtrbBYD1AUHI/dt16Mo=
 google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=

+ 20 - 1
go.work.sum

@@ -2,6 +2,7 @@
 bazil.org/fuse v0.0.0-20160811212531-371fbbdaa898 h1:SC+c6A1qTFstO9qmB86mPV2IpYme/2ZoEQ0hrP+wo+Q=
 bitbucket.org/creachadair/shell v0.0.6 h1:reJflDbKqnlnqb4Oo2pQ1/BqmY/eCWcNGHrIUO8qIzc=
 cloud.google.com/go v0.105.0/go.mod h1:PrLgOJNe5nfE9UMxKxgXj4mD3voiP+YQ6gdt6KMFOKM=
+cloud.google.com/go v0.110.0/go.mod h1:SJnCLqQ0FCFGSZMUNUf84MV3Aia54kn7pi8st7tMzaY=
 cloud.google.com/go/accessapproval v1.5.0/go.mod h1:HFy3tuiGvMdcd/u+Cu5b9NkO1pEICJ46IR82PoUdplw=
 cloud.google.com/go/accessapproval v1.6.0 h1:x0cEHro/JFPd7eS4BlEWNTMecIj2HdXjOVB5BtvwER0=
 cloud.google.com/go/accessapproval v1.6.0/go.mod h1:R0EiYnwV5fsRFiKZkPHr6mwyk2wxUJ30nL4j2pcFY2E=
@@ -75,6 +76,8 @@ cloud.google.com/go/cloudtasks v1.10.0/go.mod h1:NDSoTLkZ3+vExFEWu2UJV1arUyzVDAi
 cloud.google.com/go/compute v1.12.1/go.mod h1:e8yNOBcBONZU1vJKCvCoDw/4JQsA0dpM4x/6PIIOocU=
 cloud.google.com/go/compute v1.13.0/go.mod h1:5aPTS0cUNMIc1CE546K+Th6weJUNQErARyZtRXDJ8GE=
 cloud.google.com/go/compute v1.19.0/go.mod h1:rikpw2y+UMidAe9tISo04EHNOIf42RLYF/q8Bs93scU=
+cloud.google.com/go/compute v1.19.1/go.mod h1:6ylj3a05WF8leseCdIf77NK0g1ey+nj5IKd5/kvShxE=
+cloud.google.com/go/compute v1.19.3/go.mod h1:qxvISKp/gYnXkSAD1ppcSOveRAmzxicEv/JlizULFrI=
 cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
 cloud.google.com/go/contactcenterinsights v1.4.0/go.mod h1:L2YzkGbPsv+vMQMCADxJoT9YiTTnSEd6fEvCeHTYVck=
 cloud.google.com/go/contactcenterinsights v1.6.0 h1:jXIpfcH/VYSE1SYcPzO0n1VVb+sAamiLOgCw45JbOQk=
@@ -460,7 +463,6 @@ github.com/cli/oauth v0.8.0 h1:YTFgPXSTvvDUFti3tR4o6q7Oll2SnQ9ztLwCAn4/IOA=
 github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI=
 github.com/cncf/udpa/go v0.0.0-20220112060539-c52dc94e7fbe h1:QQ3GSy+MqSHxm/d8nCtnAiZdYFd45cYZPs8vOOIYKfk=
 github.com/cncf/udpa/go v0.0.0-20220112060539-c52dc94e7fbe/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
-github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
 github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
 github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
 github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4 h1:/inchEIKaYC1Akx+H+gqO04wryn5h75LSazbRlnya1k=
@@ -625,9 +627,12 @@ github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbu
 github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA=
 github.com/google/renameio/v2 v2.0.0 h1:UifI23ZTGY8Tt29JbYFiuyIU3eX+RNFtUwefq9qAhxg=
 github.com/google/renameio/v2 v2.0.0/go.mod h1:BtmJXm5YlszgC+TD4HOEEUFgkJP3nLxehU6hfe7jRt4=
+github.com/google/s2a-go v0.1.0/go.mod h1:OJpEgntRZo8ugHpF9hkoLJbS5dSI20XZeXJ9JVywLlM=
 github.com/google/trillian v1.3.11 h1:pPzJPkK06mvXId1LHEAJxIegGgHzzp/FUnycPYfoCMI=
 github.com/googleapis/enterprise-certificate-proxy v0.2.0/go.mod h1:8C0jb7/mgJe/9KK8Lm7X9ctZC2t60YyIpYEI16jx0Qg=
 github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8=
+github.com/googleapis/gax-go/v2 v2.8.0/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38/qKbhSAKP6QI=
+github.com/googleapis/gax-go/v2 v2.10.0/go.mod h1:4UOEnMCrxsSqQ940WnTiD6qJ63le2ev3xfyagutxiPw=
 github.com/googleapis/gnostic v0.5.5 h1:9fHAtK0uDfpveeqqo1hkEZJcFvYXAiCN3UutL8F9xHw=
 github.com/gookit/color v1.4.2 h1:tXy44JFSFkKnELV6WaMo/lLfu/meqITX3iAV52do7lk=
 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
@@ -857,6 +862,7 @@ github.com/porter-dev/api-contracts v0.2.78 h1:Iyp1DL33mPxJZQSjH8W/ylv5Ch8i30eJJ
 github.com/porter-dev/api-contracts v0.2.78/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/porter-dev/api-contracts v0.2.93/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/porter-dev/api-contracts v0.2.110/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.2.143/go.mod h1:VV5BzXd02ZdbWIPLVP+PX3GKawJSGQnxorVT2sUZALU=
 github.com/porter-dev/api-contracts v0.2.150 h1:4BMuDuRboUg5aeuQOTy+/MWK+zFmKQ6Vdgek3/1nKOk=
 github.com/porter-dev/api-contracts v0.2.150/go.mod h1:VV5BzXd02ZdbWIPLVP+PX3GKawJSGQnxorVT2sUZALU=
 github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo=
@@ -914,6 +920,7 @@ github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY52
 github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0=
 github.com/stefanberger/go-pkcs11uri v0.0.0-20201008174630-78d3cae3a980 h1:lIOOHPEbXzO3vnmx2gok1Tfs31Q8GQqKLc8vVqyQq/I=
 github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
 github.com/sylvia7788/contextcheck v1.0.4 h1:MsiVqROAdr0efZc/fOCt0c235qm9XJqHtWwM+2h2B04=
 github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 h1:kdXcSzyDtseVEc4yCz2qF8ZrQvIDBJLl4S1c3GCXmoI=
 github.com/tchap/go-patricia v2.2.6+incompatible h1:JvoDL7JSoIP2HDE8AbDH3zC8QBPxmzYe32HHy5yQ+Ck=
@@ -1016,22 +1023,29 @@ go.uber.org/automaxprocs v1.5.1/go.mod h1:BF4eumQw0P9GtnuxxovUd06vwm1o18oMzFtK66
 go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4=
 go.uber.org/zap v1.19.0 h1:mZQZefskPPCMIBCSEH0v2/iUqqLrYtaeqwD6FUGUnFE=
 go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
+golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
+golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
 golang.org/x/image v0.0.0-20190802002840-cff245a6509b h1:+qEpEAPhDZ1o0x3tHzZTQDArnOixOzGD9HUJfcg0mb4=
 golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 h1:VLliZ0d+/avPrXXH+OakdXhpJuEoBZuwh1m2j7U6Iug=
 golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028 h1:4+4C/Iv2U4fMZBiMCc98MG1In4gJY5YRhtpDNeDeHWs=
 golang.org/x/mod v0.9.0 h1:KENHtAZL2y3NLMYZeHY9DW8HW8V+kQyJsY/V9JlKvCs=
 golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20221014081412-f15817d10f9b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
 golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
 golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
 golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
 golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
 golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
 golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
 golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw=
 golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4=
+golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
+golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -1047,13 +1061,17 @@ golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s=
 golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
 golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
 google.golang.org/api v0.103.0/go.mod h1:hGtW6nK1AC+d9si/UBhw8Xli+QMOf6xyNAyJw4qU9w0=
+google.golang.org/api v0.118.0/go.mod h1:76TtD3vkgmZ66zZzp72bUUklpmQmKlhh6sYtIjYK+5E=
 google.golang.org/cloud v0.0.0-20151119220103-975617b05ea8 h1:Cpp2P6TPjujNoC5M2KHY6g7wfyLYfIWRZaSdIKfDasA=
 google.golang.org/genproto v0.0.0-20221118155620-16455021b5e6/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg=
 google.golang.org/genproto v0.0.0-20230323212658-478b75c54725/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak=
+google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU=
 google.golang.org/genproto v0.0.0-20230525234025-438c736192d0/go.mod h1:9ExIQyXL5hZrHzQceCwuSYwZZ5QZBazOcprJ5rgs3lY=
 google.golang.org/genproto v0.0.0-20230526161137-0005af68ea54/go.mod h1:zqTuNwFlFRsw5zIts5VnzLQxSRqh+CGOTVMlYbY0Eyk=
 google.golang.org/genproto/googleapis/api v0.0.0-20230525234035-dd9d682886f9/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig=
 google.golang.org/genproto/googleapis/api v0.0.0-20230526203410-71b5a4ffd15e/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig=
+google.golang.org/genproto/googleapis/bytestream v0.0.0-20230530153820-e85fd2cbaebc h1:g3hIDl0jRNd9PPTs2uBzYuaD5mQuwOkZY0vSc0LR32o=
+google.golang.org/genproto/googleapis/bytestream v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:ylj+BE99M198VPbBh6A8d9n3w8fChvyLK3wwBOjXBFA=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234030-28d5490b6b19/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20230526203410-71b5a4ffd15e/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA=
 google.golang.org/grpc v1.54.0/go.mod h1:PUSEXI6iWghWaB6lXM4knEgpJNu2qUcKfDtNci3EC2g=
@@ -1062,6 +1080,7 @@ google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0 h1:M1YKkFIboKNieVO5DLUEVzQf
 google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
 google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
 google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
 gopkg.in/airbrake/gobrake.v2 v2.0.9 h1:7z2uVWwn7oVeeugY1DtlPAy5H+KYgB1KeKTnqjNatLo=
 gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc=
 gopkg.in/cheggaaa/pb.v1 v1.0.28 h1:n1tBJnnK2r7g9OW2btFH91V92STTUevLXYFb8gy9EMk=

+ 20 - 8
internal/models/invite.go

@@ -19,19 +19,31 @@ type Invite struct {
 	// Kind is the role kind that this refers to
 	Kind string
 
-	ProjectID uint
-	UserID    uint
+	ProjectID      uint
+	UserID         uint
+	InvitingUserID uint
+	Status         InviteStatus
 }
 
+type InviteStatus string
+
+const (
+	InvitePending  InviteStatus = "pending"
+	InviteAccepted InviteStatus = "accepted"
+	InviteDeclined InviteStatus = "declined"
+)
+
 // ToInviteType generates an external Invite to be shared over REST
 func (i *Invite) ToInviteType() *types.Invite {
 	return &types.Invite{
-		ID:       i.Model.ID,
-		Token:    i.Token,
-		Email:    i.Email,
-		Expired:  i.IsExpired(),
-		Accepted: i.IsAccepted(),
-		Kind:     i.Kind,
+		ID:             i.Model.ID,
+		Token:          i.Token,
+		Email:          i.Email,
+		Expired:        i.IsExpired(),
+		Accepted:       i.IsAccepted(),
+		Kind:           i.Kind,
+		Status:         string(i.Status),
+		InvitingUserID: i.InvitingUserID,
 	}
 }
 

+ 3 - 0
internal/models/user.go

@@ -23,6 +23,9 @@ type User struct {
 	// The github user id used for login (optional)
 	GithubUserID int64
 	GoogleUserID string
+
+	AuthProvider string `json:"auth_provider"`
+	ExternalId   string `json:"external_id"`
 }
 
 // ToUserType generates an external types.User to be shared over REST

+ 14 - 0
internal/repository/gorm/invite.go

@@ -74,6 +74,20 @@ func (repo *InviteRepository) ListInvitesByProjectID(
 	return invites, nil
 }
 
+// ListInvitesByEmail finds all invites
+// for a given email
+func (repo *InviteRepository) ListInvitesByEmail(
+	email string,
+) ([]*models.Invite, error) {
+	invites := []*models.Invite{}
+
+	if err := repo.db.Where("email = ?", email).Find(&invites).Error; err != nil {
+		return nil, err
+	}
+
+	return invites, nil
+}
+
 // UpdateInvite updates an invitation in the DB
 func (repo *InviteRepository) UpdateInvite(
 	invite *models.Invite,

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

@@ -55,6 +55,15 @@ func (repo *UserRepository) ReadUserByEmail(email string) (*models.User, error)
 	return user, nil
 }
 
+// ReadUserByAuthProvider finds a single user based on their auth provider and external id
+func (repo *UserRepository) ReadUserByAuthProvider(authProvider string, externalId string) (*models.User, error) {
+	user := &models.User{}
+	if err := repo.db.Where("auth_provider = ? AND external_id = ?", authProvider, externalId).First(&user).Error; err != nil {
+		return nil, err
+	}
+	return user, nil
+}
+
 // ReadUserByGithubUserID finds a single user based on their github user id
 func (repo *UserRepository) ReadUserByGithubUserID(id int64) (*models.User, error) {
 	user := &models.User{}
@@ -104,3 +113,14 @@ func (repo *UserRepository) CheckPassword(id int, pwd string) (bool, error) {
 
 	return true, nil
 }
+
+// ListUsers retrieves all users from the database (that are not deleted)
+func (repo *UserRepository) ListUsers() ([]*models.User, error) {
+	users := make([]*models.User, 0)
+
+	if err := repo.db.Model(&models.User{}).Find(&users).Error; err != nil {
+		return nil, err
+	}
+
+	return users, nil
+}

+ 1 - 0
internal/repository/invite.go

@@ -10,6 +10,7 @@ type InviteRepository interface {
 	ReadInvite(projectID, inviteID uint) (*models.Invite, error)
 	ReadInviteByToken(token string) (*models.Invite, error)
 	ListInvitesByProjectID(projectID uint) ([]*models.Invite, error)
+	ListInvitesByEmail(email string) ([]*models.Invite, error)
 	UpdateInvite(invite *models.Invite) (*models.Invite, error)
 	DeleteInvite(invite *models.Invite) error
 }

+ 8 - 0
internal/repository/test/invite.go

@@ -87,6 +87,14 @@ func (repo *InviteRepository) ListInvitesByProjectID(
 	return res, nil
 }
 
+// ListInvitesByEmail finds all invites
+// for a given email
+func (repo *InviteRepository) ListInvitesByEmail(
+	email string,
+) ([]*models.Invite, error) {
+	return nil, errors.New("Cannot read from database")
+}
+
 // UpdateInvite updates an invitation in the DB
 func (repo *InviteRepository) UpdateInvite(
 	invite *models.Invite,

+ 10 - 0
internal/repository/test/user.go

@@ -99,6 +99,11 @@ func (repo *UserRepository) ReadUserByEmail(email string) (*models.User, error)
 	return nil, gorm.ErrRecordNotFound
 }
 
+// ReadUserByAuthProvider finds a single user based on their auth provider and external id
+func (repo *UserRepository) ReadUserByAuthProvider(authProvider string, externalId string) (*models.User, error) {
+	return nil, errors.New("Cannot read from database")
+}
+
 // ReadUserByGithubUserID finds a single user based on their github id field
 func (repo *UserRepository) ReadUserByGithubUserID(id int64) (*models.User, error) {
 	if !repo.canQuery {
@@ -183,3 +188,8 @@ func (repo *UserRepository) CheckPassword(id int, pwd string) (bool, error) {
 
 	return true, nil
 }
+
+// ListUsers retrieves all users from the database (that are not deleted)
+func (repo *UserRepository) ListUsers() ([]*models.User, error) {
+	return nil, errors.New("Cannot read from database")
+}

+ 2 - 0
internal/repository/user.go

@@ -13,9 +13,11 @@ type UserRepository interface {
 	CheckPassword(id int, pwd string) (bool, error)
 	ReadUser(id uint) (*models.User, error)
 	ReadUserByEmail(email string) (*models.User, error)
+	ReadUserByAuthProvider(authProvider string, externalId string) (*models.User, error)
 	ReadUserByGithubUserID(id int64) (*models.User, error)
 	ReadUserByGoogleUserID(id string) (*models.User, error)
 	ListUsersByIDs(ids []uint) ([]*models.User, error)
+	ListUsers() ([]*models.User, error)
 	UpdateUser(user *models.User) (*models.User, error)
 	DeleteUser(user *models.User) (*models.User, error)
 }

+ 43 - 0
package-lock.json

@@ -0,0 +1,43 @@
+{
+  "name": "porter",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "dependencies": {
+        "@tanstack/react-table": "^8.15.3"
+      }
+    },
+    "node_modules/@tanstack/react-table": {
+      "version": "8.15.3",
+      "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.15.3.tgz",
+      "integrity": "sha512-aocQ4WpWiAh7R+yxNp+DGQYXeVACh5lv2kk96DjYgFiHDCB0cOFoYMT/pM6eDOzeMXR9AvPoLeumTgq8/0qX+w==",
+      "dependencies": {
+        "@tanstack/table-core": "8.15.3"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/tannerlinsley"
+      },
+      "peerDependencies": {
+        "react": ">=16.8",
+        "react-dom": ">=16.8"
+      }
+    },
+    "node_modules/@tanstack/table-core": {
+      "version": "8.15.3",
+      "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.15.3.tgz",
+      "integrity": "sha512-wOgV0HfEvuMOv8RlqdR9MdNNqq0uyvQtP39QOvGlggHvIObOE4exS+D5LGO8LZ3LUXxId2IlUKcHDHaGujWhUg==",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/tannerlinsley"
+      }
+    }
+  }
+}