satellite/console: add endpoint to invite users to project
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: https://github.com/storj/storj/issues/5741 Change-Id: I6734f7e95be07086387fb133d6bdfd95e47cf4d9
This commit is contained in:
parent
782811c634
commit
09a7d23003
@ -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 {
|
||||
|
@ -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()
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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},
|
||||
)
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user