satellite/console,web/satellite: disallow creating multiple new invites
This change prevents multiple project invitation records from being created from a single API request. Change-Id: I01268fcc0e2f7b5f24870b032cb53f03c7ad0800
This commit is contained in:
parent
b2d2a8a744
commit
524e074a8c
@ -448,8 +448,41 @@ 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) {
|
||||
// InviteUser sends a project invitation to a user.
|
||||
func (p *Projects) InviteUser(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(ctx, w, http.StatusBadRequest, errs.New("missing project id route param"))
|
||||
return
|
||||
}
|
||||
id, err := uuid.FromString(idParam)
|
||||
if err != nil {
|
||||
p.serveJSONError(ctx, w, http.StatusBadRequest, err)
|
||||
}
|
||||
|
||||
email, ok := mux.Vars(r)["email"]
|
||||
if !ok {
|
||||
p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("missing email route param"))
|
||||
return
|
||||
}
|
||||
email = strings.TrimSpace(email)
|
||||
|
||||
_, err = p.service.InviteNewProjectMember(ctx, id, email)
|
||||
if err != nil {
|
||||
if console.ErrUnauthorized.Has(err) || console.ErrNoMembership.Has(err) {
|
||||
p.serveJSONError(ctx, w, http.StatusUnauthorized, err)
|
||||
return
|
||||
}
|
||||
p.serveJSONError(ctx, w, http.StatusInternalServerError, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ReinviteUsers resends expired project invitations.
|
||||
func (p *Projects) ReinviteUsers(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
var err error
|
||||
defer mon.Task()(&ctx)(&err)
|
||||
@ -478,7 +511,7 @@ func (p *Projects) InviteUsers(w http.ResponseWriter, r *http.Request) {
|
||||
data.Emails[i] = strings.TrimSpace(email)
|
||||
}
|
||||
|
||||
_, err = p.service.InviteProjectMembers(ctx, id, data.Emails)
|
||||
_, err = p.service.ReinviteProjectMembers(ctx, id, data.Emails)
|
||||
if err != nil {
|
||||
if console.ErrUnauthorized.Has(err) || console.ErrNoMembership.Has(err) {
|
||||
p.serveJSONError(ctx, w, http.StatusUnauthorized, err)
|
||||
|
@ -691,11 +691,8 @@ func TestWrongUser(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
endpoint: getProjectResourceUrl("invite"),
|
||||
endpoint: getProjectResourceUrl("invite") + "/" + "some@email.com",
|
||||
method: http.MethodPost,
|
||||
body: map[string]interface{}{
|
||||
"emails": []string{"some@email.com"},
|
||||
},
|
||||
},
|
||||
{
|
||||
endpoint: getProjectResourceUrl("usage-limits"),
|
||||
|
@ -287,7 +287,8 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, oidc
|
||||
projectsRouter.Handle("/{id}/members", http.HandlerFunc(projectsController.DeleteMembersAndInvitations)).Methods(http.MethodDelete, http.MethodOptions)
|
||||
projectsRouter.Handle("/{id}/salt", http.HandlerFunc(projectsController.GetSalt)).Methods(http.MethodGet, http.MethodOptions)
|
||||
projectsRouter.Handle("/{id}/members", http.HandlerFunc(projectsController.GetMembersAndInvitations)).Methods(http.MethodGet, http.MethodOptions)
|
||||
projectsRouter.Handle("/{id}/invite", server.userIDRateLimiter.Limit(http.HandlerFunc(projectsController.InviteUsers))).Methods(http.MethodPost, http.MethodOptions)
|
||||
projectsRouter.Handle("/{id}/invite/{email}", server.userIDRateLimiter.Limit(http.HandlerFunc(projectsController.InviteUser))).Methods(http.MethodPost, http.MethodOptions)
|
||||
projectsRouter.Handle("/{id}/reinvite", server.userIDRateLimiter.Limit(http.HandlerFunc(projectsController.ReinviteUsers))).Methods(http.MethodPost, http.MethodOptions)
|
||||
projectsRouter.Handle("/{id}/invite-link", http.HandlerFunc(projectsController.GetInviteLink)).Methods(http.MethodGet, http.MethodOptions)
|
||||
projectsRouter.Handle("/invitations", http.HandlerFunc(projectsController.GetUserInvitations)).Methods(http.MethodGet, http.MethodOptions)
|
||||
projectsRouter.Handle("/invitations/{id}/respond", http.HandlerFunc(projectsController.RespondToInvitation)).Methods(http.MethodPost, http.MethodOptions)
|
||||
|
@ -133,7 +133,7 @@ func TestInvitedRouting(t *testing.T) {
|
||||
|
||||
ownerCtx, err := sat.UserContext(ctx, owner.ID)
|
||||
require.NoError(t, err)
|
||||
_, err = service.InviteProjectMembers(ownerCtx, project.ID, []string{invitedEmail})
|
||||
_, err = service.InviteNewProjectMember(ownerCtx, project.ID, invitedEmail)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Valid invite for nonexistent user should redirect to registration page with
|
||||
|
@ -77,7 +77,10 @@ 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 = "An active invitation for '%s' already exists"
|
||||
activeProjInviteExistsErrMsg = "An active invitation for '%s' already exists"
|
||||
projInviteExistsErrMsg = "An invitation for '%s' already exists"
|
||||
projInviteDoesntExistErrMsg = "An invitation for '%s' does not exist"
|
||||
newInviteLimitErrMsg = "Only one new invitation can be sent at a time"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -141,11 +144,11 @@ var (
|
||||
// ErrAlreadyMember occurs when a user tries to reject an invitation to a project they're already a member of.
|
||||
ErrAlreadyMember = errs.Class("already a member")
|
||||
|
||||
// ErrProjectInviteInvalid occurs when a user tries to respond to an invitation that doesn't exist
|
||||
// ErrProjectInviteInvalid occurs when a user tries to act upon an invitation that doesn't exist
|
||||
// or has expired.
|
||||
ErrProjectInviteInvalid = errs.Class("invalid project invitation")
|
||||
|
||||
// ErrAlreadyInvited occurs when trying to invite a user who already has an unexpired invitation.
|
||||
// ErrAlreadyInvited occurs when trying to invite a user who has already been invited.
|
||||
ErrAlreadyInvited = errs.Class("user is already invited")
|
||||
|
||||
// ErrInvalidProjectLimit occurs when the requested project limit is not a non-negative integer and/or greater than the current project limit.
|
||||
@ -3710,17 +3713,62 @@ func (s *Service) RespondToProjectInvitation(ctx context.Context, projectID uuid
|
||||
return nil
|
||||
}
|
||||
|
||||
// InviteProjectMembers invites users by email to given project.
|
||||
// If an invitation already exists and has expired, it will be replaced and the user will be sent a new email.
|
||||
// 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) {
|
||||
// ProjectInvitationOption represents whether a project invitation request is for
|
||||
// inviting new members (creating records) or resending existing invitations (updating records).
|
||||
type ProjectInvitationOption int
|
||||
|
||||
const (
|
||||
// ProjectInvitationCreate indicates to insert new project member records.
|
||||
ProjectInvitationCreate ProjectInvitationOption = iota
|
||||
// ProjectInvitationResend indicates to update existing project member records.
|
||||
ProjectInvitationResend
|
||||
)
|
||||
|
||||
// ReinviteProjectMembers resends project invitations to the users specified by the given email slice.
|
||||
// The provided project ID may be the public or internal ID.
|
||||
func (s *Service) ReinviteProjectMembers(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))
|
||||
|
||||
user, err := s.getUserAndAuditLog(ctx,
|
||||
"reinvite 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)
|
||||
return s.inviteProjectMembers(ctx, user, projectID, emails, ProjectInvitationResend)
|
||||
}
|
||||
|
||||
// InviteNewProjectMember invites a user by email to the project specified by the given ID,
|
||||
// which may be its public or internal ID.
|
||||
func (s *Service) InviteNewProjectMember(ctx context.Context, projectID uuid.UUID, email string) (invite *ProjectInvitation, err error) {
|
||||
defer mon.Task()(&ctx)(&err)
|
||||
|
||||
user, err := s.getUserAndAuditLog(ctx,
|
||||
"invite project member",
|
||||
zap.String("projectID", projectID.String()),
|
||||
zap.String("email", email),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, Error.Wrap(err)
|
||||
}
|
||||
|
||||
invites, err := s.inviteProjectMembers(ctx, user, projectID, []string{email}, ProjectInvitationCreate)
|
||||
if err != nil {
|
||||
return nil, Error.Wrap(err)
|
||||
}
|
||||
|
||||
return &invites[0], nil
|
||||
}
|
||||
|
||||
// inviteProjectMembers invites users by email to the project specified by the given ID,
|
||||
// which may be its public or internal ID.
|
||||
func (s *Service) inviteProjectMembers(ctx context.Context, sender *User, projectID uuid.UUID, emails []string, opt ProjectInvitationOption) (invites []ProjectInvitation, err error) {
|
||||
defer mon.Task()(&ctx)(&err)
|
||||
|
||||
isMember, err := s.isProjectMember(ctx, sender.ID, projectID)
|
||||
if err != nil {
|
||||
return nil, Error.Wrap(err)
|
||||
}
|
||||
@ -3734,8 +3782,18 @@ func (s *Service) InviteProjectMembers(ctx context.Context, projectID uuid.UUID,
|
||||
if err != nil && !errs.Is(err, sql.ErrNoRows) {
|
||||
return nil, Error.Wrap(err)
|
||||
}
|
||||
if invite != nil && !s.IsProjectInvitationExpired(invite) {
|
||||
return nil, ErrAlreadyInvited.New(projInviteExistsErrMsg, email)
|
||||
|
||||
if invite != nil {
|
||||
// If we should only insert new records, a preexisting record is an issue
|
||||
if opt == ProjectInvitationCreate {
|
||||
return nil, ErrAlreadyInvited.New(projInviteExistsErrMsg, email)
|
||||
}
|
||||
if !s.IsProjectInvitationExpired(invite) {
|
||||
return nil, ErrAlreadyInvited.New(activeProjInviteExistsErrMsg, email)
|
||||
}
|
||||
} else if opt == ProjectInvitationResend {
|
||||
// If we should only update existing records, an absence of records is an issue
|
||||
return nil, ErrProjectInviteInvalid.New(projInviteDoesntExistErrMsg, email)
|
||||
}
|
||||
|
||||
invitedUser, unverified, err := s.store.Users().GetByEmailWithUnverified(ctx, email)
|
||||
@ -3770,7 +3828,7 @@ func (s *Service) InviteProjectMembers(ctx context.Context, projectID uuid.UUID,
|
||||
invite, err := tx.ProjectInvitations().Upsert(ctx, &ProjectInvitation{
|
||||
ProjectID: projectID,
|
||||
Email: email,
|
||||
InviterID: &user.ID,
|
||||
InviterID: &sender.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@ -3813,7 +3871,7 @@ func (s *Service) InviteProjectMembers(ctx context.Context, projectID uuid.UUID,
|
||||
ctx,
|
||||
[]post.Address{{Address: invited.Email, Name: userName}},
|
||||
&ExistingUserProjectInvitationEmail{
|
||||
InviterEmail: user.Email,
|
||||
InviterEmail: sender.Email,
|
||||
Region: s.satelliteName,
|
||||
SignInLink: inviteLink,
|
||||
},
|
||||
@ -3829,7 +3887,7 @@ func (s *Service) InviteProjectMembers(ctx context.Context, projectID uuid.UUID,
|
||||
ctx,
|
||||
[]post.Address{{Address: u.Email}},
|
||||
&UnverifiedUserProjectInvitationEmail{
|
||||
InviterEmail: user.Email,
|
||||
InviterEmail: sender.Email,
|
||||
Region: s.satelliteName,
|
||||
ActivationLink: activationLink,
|
||||
},
|
||||
@ -3842,7 +3900,7 @@ func (s *Service) InviteProjectMembers(ctx context.Context, projectID uuid.UUID,
|
||||
ctx,
|
||||
[]post.Address{{Address: email}},
|
||||
&NewUserProjectInvitationEmail{
|
||||
InviterEmail: user.Email,
|
||||
InviterEmail: sender.Email,
|
||||
Region: s.satelliteName,
|
||||
SignUpLink: inviteLink,
|
||||
},
|
||||
|
@ -2129,6 +2129,7 @@ func TestProjectInvitations(t *testing.T) {
|
||||
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
|
||||
sat := planet.Satellites[0]
|
||||
service := sat.API.Console.Service
|
||||
invitesDB := sat.DB.Console().ProjectInvitations()
|
||||
|
||||
addUser := func(t *testing.T, ctx context.Context) *console.User {
|
||||
user, err := sat.AddUser(ctx, console.CreateUser{
|
||||
@ -2155,7 +2156,7 @@ func TestProjectInvitations(t *testing.T) {
|
||||
}
|
||||
|
||||
addInvite := func(t *testing.T, ctx context.Context, project *console.Project, email string) *console.ProjectInvitation {
|
||||
invite, err := sat.DB.Console().ProjectInvitations().Upsert(ctx, &console.ProjectInvitation{
|
||||
invite, err := invitesDB.Upsert(ctx, &console.ProjectInvitation{
|
||||
ProjectID: project.ID,
|
||||
Email: email,
|
||||
InviterID: &project.OwnerID,
|
||||
@ -2176,50 +2177,56 @@ func TestProjectInvitations(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, count)
|
||||
|
||||
newInvite, err := sat.DB.Console().ProjectInvitations().Get(ctx, invite.ProjectID, invite.Email)
|
||||
newInvite, err := invitesDB.Get(ctx, invite.ProjectID, invite.Email)
|
||||
require.NoError(t, err)
|
||||
*invite = *newInvite
|
||||
}
|
||||
|
||||
t.Run("invite users", func(t *testing.T) {
|
||||
t.Run("invite and reinvite 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})
|
||||
// expect reinvitation to fail due to lack of preexisting invitation record.
|
||||
invites, err := service.ReinviteProjectMembers(ctx, project.ID, []string{user2.Email})
|
||||
require.True(t, console.ErrProjectInviteInvalid.Has(err))
|
||||
require.Empty(t, invites)
|
||||
|
||||
invite, err := service.InviteNewProjectMember(ctx, project.ID, user2.Email)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, invites, 1)
|
||||
require.NotNil(t, invite)
|
||||
|
||||
invites, err = service.GetUserProjectInvitations(ctx2)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, invites, 1)
|
||||
|
||||
// adding in a non-existent user should work.
|
||||
invites, err = service.InviteProjectMembers(ctx, project.ID, []string{user3.Email, "notauser@mail.com"})
|
||||
_, err = service.InviteNewProjectMember(ctx, project.ID, "notauser@mail.com")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, invites, 2)
|
||||
|
||||
invites, err = service.GetUserProjectInvitations(ctx3)
|
||||
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"})
|
||||
const testEmail = "other@mail.com"
|
||||
_, err = service.InviteNewProjectMember(ctx2, project.ID, testEmail)
|
||||
require.Error(t, err)
|
||||
require.True(t, console.ErrNoMembership.Has(err))
|
||||
|
||||
require.NoError(t, service.RespondToProjectInvitation(ctx2, project.ID, console.ProjectInvitationAccept))
|
||||
|
||||
// resending an active invitation should fail.
|
||||
invites, err = service.InviteProjectMembers(ctx2, project.ID, []string{user3.Email})
|
||||
// inviting a user with a preexisting invitation record should fail.
|
||||
_, err = service.InviteNewProjectMember(ctx2, project.ID, testEmail)
|
||||
require.NoError(t, err)
|
||||
_, err = service.InviteNewProjectMember(ctx2, project.ID, testEmail)
|
||||
require.True(t, console.ErrAlreadyInvited.Has(err))
|
||||
|
||||
// reinviting a user with a preexisting, unexpired invitation record should fail.
|
||||
invites, err = service.ReinviteProjectMembers(ctx2, project.ID, []string{testEmail})
|
||||
require.True(t, console.ErrAlreadyInvited.Has(err))
|
||||
require.Empty(t, invites)
|
||||
|
||||
// expire the invitation.
|
||||
user3Invite, err := sat.DB.Console().ProjectInvitations().Get(ctx, project.ID, user3.Email)
|
||||
user3Invite, err := invitesDB.Get(ctx, project.ID, testEmail)
|
||||
require.NoError(t, err)
|
||||
require.False(t, service.IsProjectInvitationExpired(user3Invite))
|
||||
oldCreatedAt := user3Invite.CreatedAt
|
||||
@ -2227,14 +2234,14 @@ func TestProjectInvitations(t *testing.T) {
|
||||
require.True(t, service.IsProjectInvitationExpired(user3Invite))
|
||||
|
||||
// resending an expired invitation should succeed.
|
||||
invites, err = service.InviteProjectMembers(ctx2, project.ID, []string{user3.Email})
|
||||
invites, err = service.ReinviteProjectMembers(ctx2, project.ID, []string{testEmail})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, invites, 1)
|
||||
require.Equal(t, user2.ID, *invites[0].InviterID)
|
||||
require.True(t, invites[0].CreatedAt.After(oldCreatedAt))
|
||||
|
||||
// inviting a project member should fail.
|
||||
_, err = service.InviteProjectMembers(ctx, project.ID, []string{user2.Email})
|
||||
_, err = service.InviteNewProjectMember(ctx, project.ID, user2.Email)
|
||||
require.Error(t, err)
|
||||
|
||||
// test inviting unverified user.
|
||||
@ -2252,9 +2259,9 @@ func TestProjectInvitations(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, unverified.Status)
|
||||
|
||||
invites, err = service.InviteProjectMembers(ctx, project.ID, []string{unverified.Email})
|
||||
invite, err = service.InviteNewProjectMember(ctx, project.ID, unverified.Email)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, unverified.Email, strings.ToLower(invites[0].Email))
|
||||
require.Equal(t, unverified.Email, strings.ToLower(invite.Email))
|
||||
|
||||
body, err := sender.Data.Get(ctx)
|
||||
require.NoError(t, err)
|
||||
@ -2396,7 +2403,7 @@ func TestProjectInvitations(t *testing.T) {
|
||||
err := service.RespondToProjectInvitation(ctx, proj.ID, console.ProjectInvitationAccept)
|
||||
require.True(t, console.ErrProjectInviteInvalid.Has(err))
|
||||
|
||||
_, err = sat.DB.Console().ProjectInvitations().Get(ctx, proj.ID, user.Email)
|
||||
_, err = invitesDB.Get(ctx, proj.ID, user.Email)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Expect no error when accepting an active invitation.
|
||||
@ -2405,7 +2412,7 @@ func TestProjectInvitations(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, service.RespondToProjectInvitation(ctx, proj.ID, console.ProjectInvitationAccept))
|
||||
|
||||
_, err = sat.DB.Console().ProjectInvitations().Get(ctx, proj.ID, user.Email)
|
||||
_, err = invitesDB.Get(ctx, proj.ID, user.Email)
|
||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||
|
||||
memberships, err := sat.DB.Console().ProjectMembers().GetByMemberID(ctx, user.ID)
|
||||
@ -2433,7 +2440,7 @@ func TestProjectInvitations(t *testing.T) {
|
||||
err := service.RespondToProjectInvitation(ctx, proj.ID, console.ProjectInvitationDecline)
|
||||
require.True(t, console.ErrProjectInviteInvalid.Has(err))
|
||||
|
||||
_, err = sat.DB.Console().ProjectInvitations().Get(ctx, proj.ID, user.Email)
|
||||
_, err = invitesDB.Get(ctx, proj.ID, user.Email)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Expect no error when rejecting an active invitation.
|
||||
@ -2442,7 +2449,7 @@ func TestProjectInvitations(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, service.RespondToProjectInvitation(ctx, proj.ID, console.ProjectInvitationDecline))
|
||||
|
||||
_, err = sat.DB.Console().ProjectInvitations().Get(ctx, proj.ID, user.Email)
|
||||
_, err = invitesDB.Get(ctx, proj.ID, user.Email)
|
||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||
|
||||
memberships, err := sat.DB.Console().ProjectMembers().GetByMemberID(ctx, user.ID)
|
||||
|
@ -50,12 +50,31 @@ export class ProjectMembersHttpApi implements ProjectMembersApi {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles inviting users to a project.
|
||||
* Handles inviting a user to a project.
|
||||
*
|
||||
* @throws Error
|
||||
*/
|
||||
public async invite(projectID: string, emails: string[]): Promise<void> {
|
||||
const path = `${this.ROOT_PATH}/${projectID}/invite`;
|
||||
public async invite(projectID: string, email: string): Promise<void> {
|
||||
const path = `${this.ROOT_PATH}/${projectID}/invite/${encodeURIComponent(email)}`;
|
||||
const httpResponse = await this.http.post(path, null);
|
||||
|
||||
if (httpResponse.ok) return;
|
||||
|
||||
const result = await httpResponse.json();
|
||||
throw new APIError({
|
||||
status: httpResponse.status,
|
||||
message: result.error || 'Failed to send project invitations',
|
||||
requestID: httpResponse.headers.get('x-request-id'),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles resending invitations to project.
|
||||
*
|
||||
* @throws Error
|
||||
*/
|
||||
public async reinvite(projectID: string, emails: string[]): Promise<void> {
|
||||
const path = `${this.ROOT_PATH}/${projectID}/reinvite`;
|
||||
const body = { emails };
|
||||
const httpResponse = await this.http.post(path, JSON.stringify(body));
|
||||
|
||||
@ -64,7 +83,7 @@ export class ProjectMembersHttpApi implements ProjectMembersApi {
|
||||
const result = await httpResponse.json();
|
||||
throw new APIError({
|
||||
status: httpResponse.status,
|
||||
message: result.error || 'Failed to send project invitations',
|
||||
message: result.error || 'Failed to resend project invitations',
|
||||
requestID: httpResponse.headers.get('x-request-id'),
|
||||
});
|
||||
}
|
||||
|
@ -120,7 +120,11 @@ const props = withDefaults(defineProps<{
|
||||
autocomplete: 'off',
|
||||
});
|
||||
|
||||
const emit = defineEmits(['showPasswordStrength', 'hidePasswordStrength', 'setData']);
|
||||
const emit = defineEmits<{
|
||||
showPasswordStrength: [];
|
||||
hidePasswordStrength: [];
|
||||
setData: [data: string];
|
||||
}>();
|
||||
|
||||
const value = ref('');
|
||||
const isPasswordShown = ref(false);
|
||||
|
@ -7,38 +7,23 @@
|
||||
<div class="modal">
|
||||
<div class="modal__header">
|
||||
<TeamMembersIcon />
|
||||
<h1 class="modal__header__title">Invite team members</h1>
|
||||
<h1 class="modal__header__title">Invite team member</h1>
|
||||
</div>
|
||||
|
||||
<p class="modal__info">
|
||||
Add team members to contribute to this project.
|
||||
Add a team member to contribute to this project.
|
||||
</p>
|
||||
|
||||
<div class="modal__input-group">
|
||||
<VInput
|
||||
v-for="(_, index) in inputs"
|
||||
:key="index"
|
||||
class="modal__input-group__item"
|
||||
label="Email"
|
||||
height="38px"
|
||||
placeholder="email@email.com"
|
||||
role-description="email"
|
||||
:error="formError"
|
||||
:max-symbols="72"
|
||||
@setData="(str) => setInput(index, str)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="modal__more">
|
||||
<div
|
||||
tabindex="0"
|
||||
class="modal__more__button"
|
||||
@click.stop="addInput"
|
||||
>
|
||||
<AddCircleIcon class="modal__more__button__icon" :class="{ inactive: isMaxInputsCount }" />
|
||||
<span class="modal__more__button__label" :class="{ inactive: isMaxInputsCount }">Add more</span>
|
||||
</div>
|
||||
</div>
|
||||
<VInput
|
||||
class="modal__input"
|
||||
label="Email"
|
||||
height="38px"
|
||||
placeholder="email@email.com"
|
||||
role-description="email"
|
||||
:error="typeof formError === 'string' ? formError : undefined"
|
||||
:max-symbols="72"
|
||||
@setData="str => email = str.trim()"
|
||||
/>
|
||||
|
||||
<div class="modal__buttons">
|
||||
<VButton
|
||||
@ -54,8 +39,8 @@
|
||||
height="48px"
|
||||
font-size="14px"
|
||||
border-radius="10px"
|
||||
:on-press="onAddUsersClick"
|
||||
:is-disabled="!isButtonActive"
|
||||
:on-press="onInviteClick"
|
||||
:is-disabled="!!formError || isLoading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -66,7 +51,6 @@
|
||||
<script setup lang='ts'>
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { EmailInput } from '@/types/EmailInput';
|
||||
import { Validator } from '@/utils/validation';
|
||||
import { AnalyticsErrorEventSource, AnalyticsEvent } from '@/utils/constants/analyticsEventNames';
|
||||
import { useNotify } from '@/utils/hooks';
|
||||
@ -75,13 +59,13 @@ import { useProjectMembersStore } from '@/store/modules/projectMembersStore';
|
||||
import { useAppStore } from '@/store/modules/appStore';
|
||||
import { useProjectsStore } from '@/store/modules/projectsStore';
|
||||
import { useAnalyticsStore } from '@/store/modules/analyticsStore';
|
||||
import { useLoading } from '@/composables/useLoading';
|
||||
|
||||
import VButton from '@/components/common/VButton.vue';
|
||||
import VModal from '@/components/common/VModal.vue';
|
||||
import VInput from '@/components/common/VInput.vue';
|
||||
|
||||
import TeamMembersIcon from '@/../static/images/team/teamMembers.svg';
|
||||
import AddCircleIcon from '@/../static/images/common/addCircle.svg';
|
||||
|
||||
const analyticsStore = useAnalyticsStore();
|
||||
const appStore = useAppStore();
|
||||
@ -89,145 +73,53 @@ const pmStore = useProjectMembersStore();
|
||||
const usersStore = useUsersStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const notify = useNotify();
|
||||
const { isLoading, withLoading } = useLoading();
|
||||
|
||||
const FIRST_PAGE = 1;
|
||||
|
||||
const inputs = ref<EmailInput[]>([new EmailInput()]);
|
||||
const formError = ref<string>('');
|
||||
const isLoading = ref<boolean>(false);
|
||||
const email = ref<string>('');
|
||||
|
||||
/**
|
||||
* Indicates if at least one input has error.
|
||||
* Returns a boolean indicating whether the email is invalid
|
||||
* or a message describing the validation error.
|
||||
*/
|
||||
const hasInputError = computed((): boolean => {
|
||||
return inputs.value.some((element: EmailInput) => {
|
||||
return element.error;
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Indicates if emails count reached maximum.
|
||||
*/
|
||||
const isMaxInputsCount = computed((): boolean => {
|
||||
return inputs.value.length > 9;
|
||||
});
|
||||
|
||||
/**
|
||||
* Indicates if add button is active.
|
||||
* Active when no errors and at least one input is not empty.
|
||||
*/
|
||||
const isButtonActive = computed((): boolean => {
|
||||
if (formError.value) return false;
|
||||
|
||||
const length = inputs.value.length;
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
if (inputs.value[i].value !== '') return true;
|
||||
const formError = computed<string | boolean>(() => {
|
||||
if (!email.value) return true;
|
||||
if (email.value.toLocaleLowerCase() === usersStore.state.user.email.toLowerCase()) {
|
||||
return `You can't add yourself to the project.`;
|
||||
}
|
||||
if (!Validator.email(email.value)) {
|
||||
return 'Please enter a valid email address.';
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
function setInput(index: number, str: string) {
|
||||
resetFormErrors(index);
|
||||
inputs.value[index].value = str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to add users related to entered emails list to current project.
|
||||
* Tries to add the user with the input email to the current project.
|
||||
*/
|
||||
async function onAddUsersClick(): Promise<void> {
|
||||
if (isLoading.value) return;
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
const length = inputs.value.length;
|
||||
const newInputsArray: EmailInput[] = [];
|
||||
let areAllEmailsValid = true;
|
||||
const emailArray: string[] = [];
|
||||
|
||||
inputs.value.forEach(elem => elem.value = elem.value.trim());
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const element = inputs.value[i];
|
||||
const isEmail = Validator.email(element.value);
|
||||
|
||||
if (isEmail) {
|
||||
emailArray.push(element.value);
|
||||
async function onInviteClick(): Promise<void> {
|
||||
await withLoading(async () => {
|
||||
try {
|
||||
await pmStore.inviteMember(email.value, projectsStore.state.selectedProject.id);
|
||||
} catch (error) {
|
||||
error.message = `Error inviting project member. ${error.message}`;
|
||||
notify.notifyError(error, AnalyticsErrorEventSource.ADD_PROJECT_MEMBER_MODAL);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEmail || element.value === '') {
|
||||
element.setError(false);
|
||||
newInputsArray.push(element);
|
||||
analyticsStore.eventTriggered(AnalyticsEvent.PROJECT_MEMBERS_INVITE_SENT);
|
||||
notify.notify('Invite sent!');
|
||||
pmStore.setSearchQuery('');
|
||||
|
||||
continue;
|
||||
try {
|
||||
await pmStore.getProjectMembers(FIRST_PAGE, projectsStore.state.selectedProject.id);
|
||||
} catch (error) {
|
||||
error.message = `Unable to fetch project members. ${error.message}`;
|
||||
notify.notifyError(error, AnalyticsErrorEventSource.ADD_PROJECT_MEMBER_MODAL);
|
||||
}
|
||||
|
||||
element.setError(true);
|
||||
newInputsArray.unshift(element);
|
||||
areAllEmailsValid = false;
|
||||
|
||||
formError.value = 'Please enter a valid email address';
|
||||
}
|
||||
|
||||
inputs.value = [...newInputsArray];
|
||||
|
||||
if (length > 3) {
|
||||
const scrollableDiv = document.querySelector('.add-user__form-container__inputs-group');
|
||||
if (scrollableDiv) {
|
||||
const scrollableDivHeight = scrollableDiv.getAttribute('offsetHeight');
|
||||
if (scrollableDivHeight) {
|
||||
scrollableDiv.scroll(0, -scrollableDivHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!areAllEmailsValid) {
|
||||
isLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (emailArray.includes(usersStore.state.user.email)) {
|
||||
notify.error(`Error adding project members. You can't add yourself to the project`, AnalyticsErrorEventSource.ADD_PROJECT_MEMBER_MODAL);
|
||||
isLoading.value = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await pmStore.inviteMembers(emailArray, projectsStore.state.selectedProject.id);
|
||||
} catch (error) {
|
||||
error.message = `Error adding project members. ${error.message}`;
|
||||
notify.notifyError(error, AnalyticsErrorEventSource.ADD_PROJECT_MEMBER_MODAL);
|
||||
isLoading.value = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
analyticsStore.eventTriggered(AnalyticsEvent.PROJECT_MEMBERS_INVITE_SENT);
|
||||
notify.notify('Invites sent!');
|
||||
pmStore.setSearchQuery('');
|
||||
|
||||
try {
|
||||
await pmStore.getProjectMembers(FIRST_PAGE, projectsStore.state.selectedProject.id);
|
||||
} catch (error) {
|
||||
error.message = `Unable to fetch project members. ${error.message}`;
|
||||
notify.notifyError(error, AnalyticsErrorEventSource.ADD_PROJECT_MEMBER_MODAL);
|
||||
}
|
||||
|
||||
closeModal();
|
||||
|
||||
isLoading.value = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds additional email input.
|
||||
*/
|
||||
function addInput(): void {
|
||||
const inputsLength = inputs.value.length;
|
||||
if (inputsLength < 10) {
|
||||
inputs.value.push(new EmailInput());
|
||||
}
|
||||
closeModal();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -236,16 +128,6 @@ function addInput(): void {
|
||||
function closeModal(): void {
|
||||
appStore.removeActiveModal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes error for selected input.
|
||||
*/
|
||||
function resetFormErrors(index: number): void {
|
||||
inputs.value[index].setError(false);
|
||||
if (!hasInputError.value) {
|
||||
formError.value = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang='scss'>
|
||||
@ -296,55 +178,10 @@ function resetFormErrors(index: number): void {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&__input-group {
|
||||
|
||||
&__item {
|
||||
border-bottom: 1px solid var(--c-grey-2);
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&__more {
|
||||
&__input {
|
||||
border-bottom: 1px solid var(--c-grey-2);
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&__button {
|
||||
width: fit-content;
|
||||
display: flex;
|
||||
column-gap: 5px;
|
||||
align-items: flex-end;
|
||||
cursor: pointer;
|
||||
|
||||
&__icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
|
||||
&.inactive {
|
||||
|
||||
:deep(path) {
|
||||
fill: var(--c-grey-5);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(path) {
|
||||
fill: var(--c-blue-3);
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-family: 'font_regular', sans-serif;
|
||||
font-size: 16px;
|
||||
text-decoration: underline;
|
||||
text-align: center;
|
||||
color: var(--c-blue-3);
|
||||
|
||||
&.inactive {
|
||||
color: var(--c-grey-5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__buttons {
|
||||
|
@ -167,7 +167,7 @@ async function resendInvites(): Promise<void> {
|
||||
analyticsStore.eventTriggered(AnalyticsEvent.RESEND_INVITE_CLICKED);
|
||||
|
||||
try {
|
||||
await pmStore.inviteMembers(pmStore.state.selectedProjectMembersEmails, projectsStore.state.selectedProject.id);
|
||||
await pmStore.reinviteMembers(pmStore.state.selectedProjectMembersEmails, projectsStore.state.selectedProject.id);
|
||||
notify.success('Invites re-sent!');
|
||||
} catch (error) {
|
||||
error.message = `Unable to resend project invitations. ${error.message}`;
|
||||
|
@ -152,7 +152,7 @@ function onResendClick(member: ProjectMemberItemModel) {
|
||||
withLoading(async () => {
|
||||
analyticsStore.eventTriggered(AnalyticsEvent.RESEND_INVITE_CLICKED);
|
||||
try {
|
||||
await pmStore.inviteMembers([member.getEmail()], projectsStore.state.selectedProject.id);
|
||||
await pmStore.reinviteMembers([member.getEmail()], projectsStore.state.selectedProject.id);
|
||||
notify.notify('Invite resent!');
|
||||
pmStore.setSearchQuery('');
|
||||
} catch (error) {
|
||||
|
@ -28,8 +28,12 @@ export const useProjectMembersStore = defineStore('projectMembers', () => {
|
||||
|
||||
const api: ProjectMembersApi = new ProjectMembersHttpApi();
|
||||
|
||||
async function inviteMembers(emails: string[], projectID: string): Promise<void> {
|
||||
await api.invite(projectID, emails);
|
||||
async function inviteMember(email: string, projectID: string): Promise<void> {
|
||||
await api.invite(projectID, email);
|
||||
}
|
||||
|
||||
async function reinviteMembers(emails: string[], projectID: string): Promise<void> {
|
||||
await api.reinvite(projectID, emails);
|
||||
}
|
||||
|
||||
async function getInviteLink(email: string, projectID: string): Promise<string> {
|
||||
@ -120,7 +124,8 @@ export const useProjectMembersStore = defineStore('projectMembers', () => {
|
||||
|
||||
return {
|
||||
state,
|
||||
inviteMembers,
|
||||
inviteMember,
|
||||
reinviteMembers,
|
||||
getInviteLink,
|
||||
deleteProjectMembers,
|
||||
getProjectMembers,
|
||||
|
@ -1,16 +0,0 @@
|
||||
// Copyright (C) 2019 Storj Labs, Inc.
|
||||
// See LICENSE for copying information.
|
||||
|
||||
export class EmailInput {
|
||||
public value: string;
|
||||
public error: boolean;
|
||||
|
||||
constructor() {
|
||||
this.value = '';
|
||||
this.error = false;
|
||||
}
|
||||
|
||||
public setError(error: boolean): void {
|
||||
this.error = error;
|
||||
}
|
||||
}
|
@ -22,14 +22,24 @@ export enum ProjectMemberOrderBy {
|
||||
export interface ProjectMembersApi {
|
||||
|
||||
/**
|
||||
* Invite members to project by user emails.
|
||||
* Invites a user to a project.
|
||||
*
|
||||
* @param projectId
|
||||
* @param emails list of project members email to add
|
||||
* @param email email of the project member to add
|
||||
*
|
||||
* @throws Error
|
||||
*/
|
||||
invite(projectId: string, emails: string[]): Promise<void>;
|
||||
invite(projectId: string, email: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Resends invitations to pending project members.
|
||||
*
|
||||
* @param projectId
|
||||
* @param emails emails of the project members whose invitations should be resent
|
||||
*
|
||||
* @throws Error
|
||||
*/
|
||||
reinvite(projectId: string, emails: string[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get invite link for the specified project and email.
|
||||
|
@ -303,7 +303,7 @@ async function resendInvite(email: string): Promise<void> {
|
||||
await withLoading(async () => {
|
||||
analyticsStore.eventTriggered(AnalyticsEvent.RESEND_INVITE_CLICKED);
|
||||
try {
|
||||
await pmStore.inviteMembers([email], selectedProject.value.id);
|
||||
await pmStore.reinviteMembers([email], selectedProject.value.id);
|
||||
notify.notify('Invite resent!');
|
||||
} catch (error) {
|
||||
error.message = `Error resending invite. ${error.message}`;
|
||||
|
@ -14,7 +14,7 @@
|
||||
<v-card-item class="pl-7 py-4">
|
||||
<template #prepend>
|
||||
<v-card-title class="font-weight-bold">
|
||||
Add Members
|
||||
Add Member
|
||||
</v-card-title>
|
||||
</template>
|
||||
|
||||
@ -33,10 +33,10 @@
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-form v-model="valid" class="pa-7 pb-4" @submit.prevent="onAddUsersClick">
|
||||
<v-form v-model="valid" class="pa-7 pb-4" @submit.prevent="onInviteClick">
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<p class="mb-5">Invite team members to join you in this project.</p>
|
||||
<p class="mb-5">Invite a team member to join you in this project.</p>
|
||||
<v-alert
|
||||
variant="tonal"
|
||||
color="info"
|
||||
@ -70,7 +70,7 @@
|
||||
<v-btn variant="outlined" color="default" block :disabled="isLoading" @click="model = false">Cancel</v-btn>
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-btn color="primary" variant="flat" block :loading="isLoading" @click="onAddUsersClick">Send Invite</v-btn>
|
||||
<v-btn color="primary" variant="flat" block :loading="isLoading" @click="onInviteClick">Send Invite</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-actions>
|
||||
@ -133,16 +133,16 @@ const emailRules: ValidationRule<string>[] = [
|
||||
/**
|
||||
* Sends a project invitation to the input email.
|
||||
*/
|
||||
async function onAddUsersClick(): Promise<void> {
|
||||
async function onInviteClick(): Promise<void> {
|
||||
if (!valid.value) return;
|
||||
|
||||
await withLoading(async () => {
|
||||
try {
|
||||
await pmStore.inviteMembers([email.value], props.projectId);
|
||||
notify.success('Invites sent!');
|
||||
await pmStore.inviteMember(email.value, props.projectId);
|
||||
notify.success('Invite sent!');
|
||||
email.value = '';
|
||||
} catch (error) {
|
||||
error.message = `Error adding project members. ${error.message}`;
|
||||
error.message = `Error inviting project member. ${error.message}`;
|
||||
notify.notifyError(error, AnalyticsErrorEventSource.ADD_PROJECT_MEMBER_MODAL);
|
||||
return;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user