From a148b8afbece5588398e09521e38e4d6062b46e6 Mon Sep 17 00:00:00 2001 From: dgtown Date: Fri, 17 May 2024 17:27:14 -0400 Subject: [PATCH] wip --- api/server/authn/handler.go | 82 ++++--- api/server/handlers/invite/create.go | 136 ++++++++++++ api/server/handlers/invite/delete.go | 36 ++++ api/server/handlers/invite/invite_ce.go | 69 ------ api/server/handlers/invite/invite_ee.go | 45 ---- api/server/handlers/invite/list.go | 43 ++++ api/server/handlers/invite/update_role.go | 43 ++++ api/server/handlers/user/create.go | 121 +++++++---- api/server/handlers/user/invite_list.go | 117 ++++++++++ api/server/handlers/user/invite_respond.go | 145 +++++++++++++ api/server/router/invite.go | 26 --- api/server/router/user.go | 51 ++++- api/types/invite.go | 14 +- api/types/user.go | 16 +- dashboard/package.json | 1 + dashboard/src/components/UserInviteModal.tsx | 204 ++++++++++++++++++ dashboard/src/lib/invites/types.ts | 19 ++ dashboard/src/main/auth/LoginWrapper.tsx | 34 ++- dashboard/src/main/home/Home.tsx | 42 +++- .../main/home/project-settings/InviteList.tsx | 10 +- dashboard/src/shared/api.tsx | 20 ++ dashboard/src/shared/auth/AuthnContext.tsx | 198 ++++++++++++----- dashboard/src/shared/auth/sdk.ts | 178 +++++++++++++++ go.mod | 2 +- internal/models/invite.go | 28 ++- internal/repository/gorm/invite.go | 14 ++ internal/repository/invite.go | 1 + internal/repository/test/invite.go | 8 + internal/telemetry/span.go | 2 + package-lock.json | 43 ++++ 30 files changed, 1438 insertions(+), 310 deletions(-) create mode 100644 api/server/handlers/invite/create.go create mode 100644 api/server/handlers/invite/delete.go delete mode 100644 api/server/handlers/invite/invite_ce.go delete mode 100644 api/server/handlers/invite/invite_ee.go create mode 100644 api/server/handlers/invite/list.go create mode 100644 api/server/handlers/invite/update_role.go create mode 100644 api/server/handlers/user/invite_list.go create mode 100644 api/server/handlers/user/invite_respond.go create mode 100644 dashboard/src/components/UserInviteModal.tsx create mode 100644 dashboard/src/lib/invites/types.ts create mode 100644 dashboard/src/shared/auth/sdk.ts create mode 100644 package-lock.json diff --git a/api/server/authn/handler.go b/api/server/authn/handler.go index 44c0ed4781..ada554595c 100644 --- a/api/server/authn/handler.go +++ b/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( diff --git a/api/server/handlers/invite/create.go b/api/server/handlers/invite/create.go new file mode 100644 index 0000000000..8da05a37f7 --- /dev/null +++ b/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( + // ¬ifier.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 +} diff --git a/api/server/handlers/invite/delete.go b/api/server/handlers/invite/delete.go new file mode 100644 index 0000000000..d1360fcc39 --- /dev/null +++ b/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) +} diff --git a/api/server/handlers/invite/invite_ce.go b/api/server/handlers/invite/invite_ce.go deleted file mode 100644 index 1fe83af516..0000000000 --- a/api/server/handlers/invite/invite_ce.go +++ /dev/null @@ -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") -} diff --git a/api/server/handlers/invite/invite_ee.go b/api/server/handlers/invite/invite_ee.go deleted file mode 100644 index 42d43e9588..0000000000 --- a/api/server/handlers/invite/invite_ee.go +++ /dev/null @@ -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 -} diff --git a/api/server/handlers/invite/list.go b/api/server/handlers/invite/list.go new file mode 100644 index 0000000000..4f4221cd1e --- /dev/null +++ b/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) +} diff --git a/api/server/handlers/invite/update_role.go b/api/server/handlers/invite/update_role.go new file mode 100644 index 0000000000..f053b2eef3 --- /dev/null +++ b/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) +} diff --git a/api/server/handlers/user/create.go b/api/server/handlers/user/create.go index 403a265346..d8ad759d92 100644 --- a/api/server/handlers/user/create.go +++ b/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 } diff --git a/api/server/handlers/user/invite_list.go b/api/server/handlers/user/invite_list.go new file mode 100644 index 0000000000..675e00673b --- /dev/null +++ b/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) +} diff --git a/api/server/handlers/user/invite_respond.go b/api/server/handlers/user/invite_respond.go new file mode 100644 index 0000000000..c1adc6e44a --- /dev/null +++ b/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 +} diff --git a/api/server/router/invite.go b/api/server/router/invite.go index f63c5f094e..7df42e3da7 100644 --- a/api/server/router/invite.go +++ b/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{ diff --git a/api/server/router/user.go b/api/server/router/user.go index bd52464972..5902122727 100644 --- a/api/server/router/user.go +++ b/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{ diff --git a/api/types/invite.go b/api/types/invite.go index 9ce34e4629..f1423743e7 100644 --- a/api/types/invite.go +++ b/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 diff --git a/api/types/user.go b/api/types/user.go index 41b33b6b34..d766a866af 100644 --- a/api/types/user.go +++ b/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 diff --git a/dashboard/package.json b/dashboard/package.json index 4be9258892..7ce5c54212 100644 --- a/dashboard/package.json +++ b/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", diff --git a/dashboard/src/components/UserInviteModal.tsx b/dashboard/src/components/UserInviteModal.tsx new file mode 100644 index 0000000000..d76b046f75 --- /dev/null +++ b/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 = ({ invites, closeModal }) => { + const { checkInvites } = useContext(AuthnContext); + const [inviteMap, setInviteMap] = useState({}); + const [errorText, setErrorText] = useState(""); + + 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 ( + + Pending project invites + + <> + + Accept or decline all pending project invites to proceed. + + + + + {invites.map((invite, i) => ( + + {invite.project.name} + {invite.inviter.email} + { + declineInvite(invite); + }} + isSelected={isDeclined(invite)} + > + close + + { + acceptInvite(invite); + }} + isSelected={isAccepted(invite)} + > + check + + + ))} + + + + + + + ); +}; + +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; +`; \ No newline at end of file diff --git a/dashboard/src/lib/invites/types.ts b/dashboard/src/lib/invites/types.ts new file mode 100644 index 0000000000..34a02bd420 --- /dev/null +++ b/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; \ No newline at end of file diff --git a/dashboard/src/main/auth/LoginWrapper.tsx b/dashboard/src/main/auth/LoginWrapper.tsx index bbcc7c9c90..5abbe539a9 100644 --- a/dashboard/src/main/auth/LoginWrapper.tsx +++ b/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; @@ -20,8 +34,9 @@ const getWindowDimensions = () => { }; const LoginWrapper: React.FC = ({ authenticate }) => { + const [legacyLogin, setLegacyLogin] = useState(false); const [windowDimensions, setWindowDimensions] = useState( - getWindowDimensions() + getWindowDimensions() ); const handleResize = () => { @@ -52,6 +67,13 @@ const LoginWrapper: React.FC = ({ authenticate }) => { Welcome back to Porter + {setLegacyLogin(!legacyLogin)}} + > + Legacy log-in flow + + Read the Porter docs @@ -71,7 +93,11 @@ const LoginWrapper: React.FC = ({ authenticate }) => { )} - + {legacyLogin ? ( + + ) : ( + + )} ); diff --git a/dashboard/src/main/home/Home.tsx b/dashboard/src/main/home/Home.tsx index b2ff094b4a..4004e66333 100644 --- a/dashboard/src/main/home/Home.tsx +++ b/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) => { 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) => { }; 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) => { .getProjects("", {}, { 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) => { } 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) => { 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) => { )} + {inviteModalOpen && ( + { + setInviteModalOpen(false) + props.history.push("/") + }}/> + )} diff --git a/dashboard/src/main/home/project-settings/InviteList.tsx b/dashboard/src/main/home/project-settings/InviteList.tsx index 80d6511fb7..4b37d4e3b9 100644 --- a/dashboard/src/main/home/project-settings/InviteList.tsx +++ b/dashboard/src/main/home/project-settings/InviteList.tsx @@ -48,6 +48,8 @@ const InvitePage: React.FunctionComponent = ({}) => { const [isHTTPS] = useState(() => window.location.protocol === "https:"); const [showNewGroupModal, setShowNewGroupModal] = useState(false); + console.log(invites); + useEffect(() => { api .getAvailableRoles("", {}, { project_id: currentProject?.id }) @@ -79,7 +81,7 @@ const InvitePage: React.FunctionComponent = ({}) => { } ); 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 = ({}) => { } catch (err) { console.log(err); } + console.log(collaborators) setInvites([...invites, ...collaborators]); setIsLoading(false); }; @@ -104,14 +107,15 @@ const InvitePage: React.FunctionComponent = ({}) => { 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); diff --git a/dashboard/src/shared/api.tsx b/dashboard/src/shared/api.tsx index 3899a51360..de5e90182f 100644 --- a/dashboard/src/shared/api.tsx +++ b/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, }; diff --git a/dashboard/src/shared/auth/AuthnContext.tsx b/dashboard/src/shared/auth/AuthnContext.tsx index 262365f800..1e63c26d03 100644 --- a/dashboard/src/shared/auth/AuthnContext.tsx +++ b/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; handleLogOut: () => void; - session: Session | null; + session: Session | null; + invites: Invite[]; + checkInvites: () => void; + invitesLoading: boolean; }; export const AuthnContext = React.createContext(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(""); const [hasInfo, setHasInfo] = useState(false); - const [session, setSession] = useState(null); + const [session, setSession] = useState(null) + const [logoutUrl, setLogoutUrl] = useState() + const [invites, setInvites] = useState([]) const [local, setLocal] = useState(false); + console.log(invites) + const authenticate = async (): Promise => { - 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("", {}, {}) - .then(() => { - setIsLoggedIn(false); - setIsEmailVerified(false); - clearContext?.(); - localStorage.clear(); - }) - .catch((err) => { - setCurrentError?.(err.response?.data.errors[0]); - }); + if (isPorterAuthenticated) { + api + .logOutUser("", {}, {}) + .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( + "", + {}, + {} + ) + .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 ( {children} diff --git a/dashboard/src/shared/auth/sdk.ts b/dashboard/src/shared/auth/sdk.ts new file mode 100644 index 0000000000..e9ea4e5997 --- /dev/null +++ b/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) | undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setFlow: React.Dispatch> | undefined, + defaultNav: string | undefined, + fatalToDash = false, +) => { + const history = useHistory() + + return useCallback( + (error: AxiosError): Promise => { + 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], + ) +} \ No newline at end of file diff --git a/go.mod b/go.mod index 96b70c1f75..708e41c78d 100644 --- a/go.mod +++ b/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 diff --git a/internal/models/invite.go b/internal/models/invite.go index 538c52014f..677bb0f0b4 100644 --- a/internal/models/invite.go +++ b/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, } } diff --git a/internal/repository/gorm/invite.go b/internal/repository/gorm/invite.go index 2b00be9db8..5b15cb022b 100644 --- a/internal/repository/gorm/invite.go +++ b/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, diff --git a/internal/repository/invite.go b/internal/repository/invite.go index 44b899f19c..478f7909cd 100644 --- a/internal/repository/invite.go +++ b/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 } diff --git a/internal/repository/test/invite.go b/internal/repository/test/invite.go index c42e5829f3..40e39c40ca 100644 --- a/internal/repository/test/invite.go +++ b/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, diff --git a/internal/telemetry/span.go b/internal/telemetry/span.go index 04769545e8..a270e9747f 100644 --- a/internal/telemetry/span.go +++ b/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))) } } } diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000..8e5bbad507 --- /dev/null +++ b/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" + } + } + } +}