diff --git a/satellite/api.go b/satellite/api.go index c49cdd42d..951d239e3 100644 --- a/satellite/api.go +++ b/satellite/api.go @@ -602,6 +602,7 @@ func NewAPI(log *zap.Logger, full *identity.FullIdentity, db DB, peer.Console.AuthTokens, peer.Mail.Service, externalAddress, + consoleConfig.SatelliteName, consoleConfig.Config, ) if err != nil { diff --git a/satellite/console/consoleweb/consoleapi/projects.go b/satellite/console/consoleweb/consoleapi/projects.go index 3c4bda306..1d0a8215a 100644 --- a/satellite/console/consoleweb/consoleapi/projects.go +++ b/satellite/console/consoleweb/consoleapi/projects.go @@ -65,6 +65,38 @@ func (p *Projects) GetSalt(w http.ResponseWriter, r *http.Request) { } } +// InviteUsers sends invites to a given project(id) to the given users (emails). +func (p *Projects) InviteUsers(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var err error + defer mon.Task()(&ctx)(&err) + + idParam, ok := mux.Vars(r)["id"] + if !ok { + p.serveJSONError(w, http.StatusBadRequest, errs.New("missing project id route param")) + return + } + id, err := uuid.FromString(idParam) + if err != nil { + p.serveJSONError(w, http.StatusBadRequest, err) + } + + var data struct { + Emails []string `json:"emails"` + } + + err = json.NewDecoder(r.Body).Decode(&data) + if err != nil { + p.serveJSONError(w, http.StatusBadRequest, err) + return + } + + _, err = p.service.InviteProjectMembers(ctx, id, data.Emails) + if err != nil { + p.serveJSONError(w, http.StatusInternalServerError, err) + } +} + // GetUserInvitations returns the user's pending project member invitations. func (p *Projects) GetUserInvitations(w http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/satellite/console/consoleweb/consoleql/mutation_test.go b/satellite/console/consoleweb/consoleql/mutation_test.go index a3a316c62..1ef00c393 100644 --- a/satellite/console/consoleweb/consoleql/mutation_test.go +++ b/satellite/console/consoleweb/consoleql/mutation_test.go @@ -117,6 +117,7 @@ func TestGraphqlMutation(t *testing.T) { }, &consoleauth.Hmac{Secret: []byte("my-suppa-secret-key")}), nil, "", + "", console.Config{ PasswordCost: console.TestPasswordCost, DefaultProjectLimit: 5, diff --git a/satellite/console/consoleweb/consoleql/query_test.go b/satellite/console/consoleweb/consoleql/query_test.go index 23f3eda4e..ab0362077 100644 --- a/satellite/console/consoleweb/consoleql/query_test.go +++ b/satellite/console/consoleweb/consoleql/query_test.go @@ -101,6 +101,7 @@ func TestGraphqlQuery(t *testing.T) { }, &consoleauth.Hmac{Secret: []byte("my-suppa-secret-key")}), nil, "", + "", console.Config{ PasswordCost: console.TestPasswordCost, DefaultProjectLimit: 5, diff --git a/satellite/console/consoleweb/server.go b/satellite/console/consoleweb/server.go index bea36788f..02cf41bfd 100644 --- a/satellite/console/consoleweb/server.go +++ b/satellite/console/consoleweb/server.go @@ -262,6 +262,7 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, oidc projectsController := consoleapi.NewProjects(logger, service) projectsRouter := router.PathPrefix("/api/v0/projects").Subrouter() projectsRouter.Handle("/{id}/salt", server.withAuth(http.HandlerFunc(projectsController.GetSalt))).Methods(http.MethodGet) + projectsRouter.Handle("/{id}/invite", server.withAuth(http.HandlerFunc(projectsController.InviteUsers))).Methods(http.MethodPost) projectsRouter.Handle("/invitations", server.withAuth(http.HandlerFunc(projectsController.GetUserInvitations))).Methods(http.MethodGet) projectsRouter.Handle("/invitations/{id}/respond", server.withAuth(http.HandlerFunc(projectsController.RespondToInvitation))).Methods(http.MethodPost) diff --git a/satellite/console/service.go b/satellite/console/service.go index a0a784065..196228e9b 100644 --- a/satellite/console/service.go +++ b/satellite/console/service.go @@ -75,6 +75,7 @@ const ( projInviteInvalidErrMsg = "The invitation has expired or is invalid" projInviteAlreadyMemberErrMsg = "You are already a member of the project" projInviteResponseInvalidErrMsg = "Invalid project member invitation response" + projInviteExistsErrMsg = "User has already been invited" ) var ( @@ -141,6 +142,9 @@ var ( // ErrProjectInviteInvalid occurs when a user tries to respond to an invitation that doesn't exist // or has expired. ErrProjectInviteInvalid = errs.Class("invalid project invitation") + + // ErrProjectInviteExists occurs when a user is invited to a project they've already been invited to. + ErrProjectInviteExists = errs.Class("user already invited to project") ) // Service is handling accounts related logic. @@ -163,6 +167,7 @@ type Service struct { mailService *mailservice.Service satelliteAddress string + satelliteName string config Config } @@ -226,7 +231,7 @@ type Payments struct { } // NewService returns new instance of Service. -func NewService(log *zap.Logger, store DB, restKeys RESTKeys, projectAccounting accounting.ProjectAccounting, projectUsage *accounting.Service, buckets buckets.DB, accounts payments.Accounts, depositWallets payments.DepositWallets, billing billing.TransactionsDB, analytics *analytics.Service, tokens *consoleauth.Service, mailService *mailservice.Service, satelliteAddress string, config Config) (*Service, error) { +func NewService(log *zap.Logger, store DB, restKeys RESTKeys, projectAccounting accounting.ProjectAccounting, projectUsage *accounting.Service, buckets buckets.DB, accounts payments.Accounts, depositWallets payments.DepositWallets, billing billing.TransactionsDB, analytics *analytics.Service, tokens *consoleauth.Service, mailService *mailservice.Service, satelliteAddress string, satelliteName string, config Config) (*Service, error) { if store == nil { return nil, errs.New("store can't be nil") } @@ -271,6 +276,7 @@ func NewService(log *zap.Logger, store DB, restKeys RESTKeys, projectAccounting tokens: tokens, mailService: mailService, satelliteAddress: satelliteAddress, + satelliteName: satelliteName, config: config, }, nil } @@ -3576,3 +3582,99 @@ func (s *Service) RespondToProjectInvitation(ctx context.Context, projectID uuid return nil } + +// InviteProjectMembers invites users by email to given project. +// Email addresses not belonging to a user are ignored. +// projectID here may be project.PublicID or project.ID. +func (s *Service) InviteProjectMembers(ctx context.Context, projectID uuid.UUID, emails []string) (invites []ProjectInvitation, err error) { + defer mon.Task()(&ctx)(&err) + user, err := s.getUserAndAuditLog(ctx, "invite project members", zap.String("projectID", projectID.String()), zap.Strings("emails", emails)) + if err != nil { + return nil, Error.Wrap(err) + } + + isMember, err := s.isProjectMember(ctx, user.ID, projectID) + if err != nil { + return nil, Error.Wrap(err) + } + projectID = isMember.project.ID + + // collect user querying errors + users := make([]*User, 0) + for _, email := range emails { + invitedUser, err := s.store.Users().GetByEmail(ctx, email) + if err == nil { + _, err = s.isProjectMember(ctx, invitedUser.ID, projectID) + if err != nil && !ErrNoMembership.Has(err) { + return nil, Error.Wrap(err) + } else if err == nil { + return nil, ErrAlreadyMember.New("%s is already a member", email) + } + + invite, err := s.store.ProjectInvitations().Get(ctx, projectID, email) + if err != nil && !errs.Is(err, sql.ErrNoRows) { + return nil, Error.Wrap(err) + } + if invite != nil && time.Now().After(invite.CreatedAt.Add(s.config.ProjectInvitationExpiration)) { + // delete expired invite + err := s.store.ProjectInvitations().Delete(ctx, projectID, invitedUser.Email) + if err != nil { + s.log.Warn("error deleting project invitation", + zap.Error(err), + zap.String("email", invitedUser.Email), + zap.String("projectID", projectID.String()), + ) + } + } else if invite != nil && !time.Now().After(invite.CreatedAt.Add(s.config.ProjectInvitationExpiration)) { + return nil, ErrProjectInviteExists.New(projInviteExistsErrMsg) + } + users = append(users, invitedUser) + } else if !errs.Is(err, sql.ErrNoRows) { + return nil, Error.Wrap(err) + } + + } + + signIn := fmt.Sprintf("%s/login", s.satelliteAddress) + + // add project invites in transaction scope + err = s.store.WithTx(ctx, func(ctx context.Context, tx DBTx) error { + for _, invited := range users { + invite, err := tx.ProjectInvitations().Insert(ctx, &ProjectInvitation{ + ProjectID: projectID, + Email: invited.Email, + InviterID: &user.ID, + }) + if err != nil { + if dbx.IsConstraintError(err) { + // should not happen, but just in case. + return errs.New("%s is already invited", invited.Email) + } + return err + } + invites = append(invites, *invite) + } + return nil + }) + if err != nil { + return nil, Error.Wrap(err) + } + + for _, invited := range users { + userName := invited.ShortName + if userName == "" { + userName = invited.FullName + } + s.mailService.SendRenderedAsync( + ctx, + []post.Address{{Address: invited.Email, Name: userName}}, + &ExistingUserProjectInvitationEmail{ + InviterEmail: user.Email, + Region: s.satelliteName, + SignInLink: fmt.Sprintf("%s?email=%s", signIn, invited.Email), + }, + ) + } + + return invites, nil +} diff --git a/satellite/console/service_test.go b/satellite/console/service_test.go index e29c5ea7a..4d5c2daee 100644 --- a/satellite/console/service_test.go +++ b/satellite/console/service_test.go @@ -2002,6 +2002,60 @@ func TestProjectInvitations(t *testing.T) { return setInviteDate(ctx, invite, createdAt) } + t.Run("invite users", func(t *testing.T) { + user, ctx := getUserAndCtx(t) + user2, ctx2 := getUserAndCtx(t) + user3, ctx3 := getUserAndCtx(t) + + project, err := sat.AddProject(ctx, user.ID, "Test Project") + require.NoError(t, err) + + invites, err := service.InviteProjectMembers(ctx, project.ID, []string{user2.Email}) + require.NoError(t, err) + require.Len(t, invites, 1) + + invites, err = service.GetUserProjectInvitations(ctx2) + require.NoError(t, err) + require.Len(t, invites, 1) + + // adding in a non-existent user should not fail the invitation. + invites, err = service.InviteProjectMembers(ctx, project.ID, []string{user3.Email, "notauser@mail.com"}) + require.NoError(t, err) + require.Len(t, invites, 1) + + invites, err = service.GetUserProjectInvitations(ctx3) + require.NoError(t, err) + require.Len(t, invites, 1) + invite := invites[0] + + // inviting the same user again should fail if existing invite hasn't expired. + _, err = service.InviteProjectMembers(ctx, project.ID, []string{user3.Email}) + require.Error(t, err) + + // expire the invitation. + setInviteDate(ctx, &invite, time.Now().Add(-sat.Config.Console.ProjectInvitationExpiration)) + + // inviting the same user again should succeed because the existing invite has expired. + invites, err = service.InviteProjectMembers(ctx, project.ID, []string{user3.Email}) + require.NoError(t, err) + require.Len(t, invites, 1) + + // prevent unauthorized users from inviting others (user2 is not a member of the project yet). + _, err = service.InviteProjectMembers(ctx2, project.ID, []string{"other@mail.com"}) + require.Error(t, err) + require.True(t, console.ErrNoMembership.Has(err)) + + require.NoError(t, service.RespondToProjectInvitation(ctx2, project.ID, console.ProjectInvitationAccept)) + + // now that user2 is a member, they can invite others. + _, err = service.InviteProjectMembers(ctx2, project.ID, []string{"other@mail.com"}) + require.NoError(t, err) + + // inviting a project member should fail. + _, err = service.InviteProjectMembers(ctx, project.ID, []string{user2.Email}) + require.Error(t, err) + }) + t.Run("get invitation", func(t *testing.T) { user, ctx := getUserAndCtx(t) diff --git a/satellite/payments/stripe/accounts_test.go b/satellite/payments/stripe/accounts_test.go index 17e6f528a..b438c5440 100644 --- a/satellite/payments/stripe/accounts_test.go +++ b/satellite/payments/stripe/accounts_test.go @@ -95,6 +95,7 @@ func TestSignupCouponCodes(t *testing.T) { }, &consoleauth.Hmac{Secret: []byte("my-suppa-secret-key")}), nil, "", + "", console.Config{PasswordCost: console.TestPasswordCost, DefaultProjectLimit: 5}, )