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:
Jeremy Wharton 2023-10-16 15:29:36 -05:00
parent b2d2a8a744
commit 524e074a8c
16 changed files with 250 additions and 295 deletions

View File

@ -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)

View File

@ -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"),

View File

@ -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)

View File

@ -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

View File

@ -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,
},

View File

@ -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)

View File

@ -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'),
});
}

View File

@ -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);

View File

@ -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 {

View File

@ -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}`;

View File

@ -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) {

View File

@ -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,

View File

@ -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;
}
}

View File

@ -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.

View File

@ -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}`;

View File

@ -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;
}