dgtown 2 år sedan
förälder
incheckning
a148b8afbe

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

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

+ 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

+ 1 - 0
dashboard/package.json

@@ -17,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>;

+ 30 - 4
dashboard/src/main/auth/LoginWrapper.tsx

@@ -1,14 +1,28 @@
-import React, { useEffect, useState } from "react";
+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>;
@@ -20,8 +34,9 @@ const getWindowDimensions = () => {
 };
 
 const LoginWrapper: React.FC<Props> = ({ authenticate }) => {
+  const [legacyLogin, setLegacyLogin] = useState(false);
   const [windowDimensions, setWindowDimensions] = useState(
-    getWindowDimensions()
+      getWindowDimensions()
   );
 
   const handleResize = () => {
@@ -52,6 +67,13 @@ const LoginWrapper: React.FC<Props> = ({ authenticate }) => {
             <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>
@@ -71,7 +93,11 @@ const LoginWrapper: React.FC<Props> = ({ authenticate }) => {
           </Flex>
         )}
         <Spacer y={2} />
-        <Login authenticate={authenticate} />
+        {legacyLogin ? (
+          <Login authenticate={authenticate} />
+        ) : (
+            <OryLogin authenticate={authenticate} />
+        )}
       </Wrapper>
     </StyledLogin>
   );

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

@@ -70,6 +70,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);
 
@@ -128,6 +130,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"]);
@@ -149,6 +167,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);
@@ -162,11 +181,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;
@@ -227,6 +248,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();
@@ -261,7 +291,7 @@ const Home: React.FC<Props> = (props) => {
     return () => {
       setCanCreateProject(false);
     };
-  }, []);
+  }, [user]);
 
   // Hacky legacy shim for remote cluster refresh until Context is properly split
   useEffect(() => {
@@ -702,6 +732,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

@@ -544,6 +544,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;
@@ -2196,6 +2210,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<
@@ -4065,4 +4081,8 @@ export default {
 
   // system status
   systemStatusHistory,
+
+
+  listUserInvites,
+  respondUserInvites,
 };

+ 141 - 57
dashboard/src/shared/auth/AuthnContext.tsx

@@ -1,25 +1,45 @@
-import React, { useContext, useEffect, useState } from "react";
-import { type Session } from "@ory/client";
+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 LoginWrapper from "../../main/auth/LoginWrapper";
+import Login from "../../main/auth/Login";
 import Register from "../../main/auth/Register";
 import ResetPasswordFinalize from "../../main/auth/ResetPasswordFinalize";
 import ResetPasswordInit from "../../main/auth/ResetPasswordInit";
 import SetInfo from "../../main/auth/SetInfo";
 import VerifyEmail from "../../main/auth/VerifyEmail";
 import CurrentError from "../../main/CurrentError";
-import { ory } from "./ory";
+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;
+    session: Session | null;
+    invites: Invite[];
+    checkInvites: () => void;
+    invitesLoading: boolean;
 };
 
 export const AuthnContext = React.createContext<AuthnState | null>(null);
@@ -39,69 +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 [logoutUrl, setLogoutUrl] = useState<string>("");
   const [hasInfo, setHasInfo] = useState(false);
-  const [session, setSession] = useState<Session | null>(null);
+  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> => {
-    try {
-      const { data: authData } = await api.checkAuth("", {}, {});
-      if (authData) {
-        setUser?.(authData.id, authData.email);
-        setIsLoggedIn(true);
-        setIsEmailVerified(authData.email_verified);
-        setHasInfo(authData.company_name && true);
-        setIsLoading(false);
-        setUserId(authData.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}) => {
 
-    try {
-      const { data: orySession } = await ory.toSession();
-      const { data: logOutData } = await ory.createBrowserLogoutFlow();
-      setLogoutUrl(logOutData.logout_url);
-      setSession(orySession);
-    } catch {
-      setSession(null);
-      setLogoutUrl("");
-    }
-  };
+                      // 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]);
+           })
+   }
 
-    window.location.replace(logoutUrl);
+   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(() => {});
 
@@ -201,10 +282,13 @@ const AuthnProvider = ({
   return (
     <AuthnContext.Provider
       value={{
-        userId,
-        authenticate,
-        handleLogOut,
-        session,
+          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],
+    )
+}

+ 1 - 1
go.mod

@@ -244,7 +244,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

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

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

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

+ 2 - 0
internal/telemetry/span.go

@@ -92,6 +92,8 @@ func WithAttributes(span trace.Span, attrs ...AttributeKV) {
 				zone, offset := val.Zone()
 				span.SetAttributes(attribute.String(prefixSpanKey(fmt.Sprintf("%s-timezone", string(attr.Key))), zone))
 				span.SetAttributes(attribute.Int(prefixSpanKey(fmt.Sprintf("%s-offset", string(attr.Key))), offset))
+			default:
+				span.SetAttributes(attribute.String(prefixSpanKey(string(attr.Key)), fmt.Sprintf("%v", val)))
 			}
 		}
 	}

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