Skip to content

Commit

Permalink
satellite/console: add endpoint to invite users to project
Browse files Browse the repository at this point in the history
This change adds a new endpoint that uses the new project invite flow's
 functionality instead of directly adding users to a project's members.

Issue: #5741

Change-Id: I6734f7e95be07086387fb133d6bdfd95e47cf4d9
  • Loading branch information
wilfred-asomanii committed Jun 13, 2023
1 parent 782811c commit 09a7d23
Show file tree
Hide file tree
Showing 8 changed files with 194 additions and 1 deletion.
1 change: 1 addition & 0 deletions satellite/api.go
Expand Up @@ -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 {
Expand Down
32 changes: 32 additions & 0 deletions satellite/console/consoleweb/consoleapi/projects.go
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions satellite/console/consoleweb/consoleql/mutation_test.go
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions satellite/console/consoleweb/consoleql/query_test.go
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions satellite/console/consoleweb/server.go
Expand Up @@ -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)

Expand Down
104 changes: 103 additions & 1 deletion satellite/console/service.go
Expand Up @@ -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 (
Expand Down Expand Up @@ -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.
Expand All @@ -163,6 +167,7 @@ type Service struct {
mailService *mailservice.Service

satelliteAddress string
satelliteName string

config Config
}
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
54 changes: 54 additions & 0 deletions satellite/console/service_test.go
Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions satellite/payments/stripe/accounts_test.go
Expand Up @@ -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},
)

Expand Down

0 comments on commit 09a7d23

Please sign in to comment.