From 706cd0b9fb420abc3dcdadcd08693490f7ebe835 Mon Sep 17 00:00:00 2001 From: Jeremy Wharton Date: Mon, 26 Jun 2023 11:53:46 -0500 Subject: [PATCH] satellite/console: allow for adding unregistered project members This change allows members without an account to be invited to a project. The link in the invitation email will redirect these users to the registration page containing custom text describing the invitation. Resolves #5353 Change-Id: I6cba91e57c551ca13c7a9ae49150fc1d374cd6b5 --- satellite/console/consoleweb/server.go | 39 ++++++++++++++++++- satellite/console/consoleweb/server_test.go | 38 +++++++++--------- satellite/console/service.go | 28 +++++++++---- satellite/console/service_test.go | 13 ++++--- .../src/components/common/VInput.vue | 5 +++ .../src/views/registration/RegisterArea.vue | 18 +++++++-- 6 files changed, 105 insertions(+), 36 deletions(-) diff --git a/satellite/console/consoleweb/server.go b/satellite/console/consoleweb/server.go index 6aa270f62..8a81aff9b 100644 --- a/satellite/console/consoleweb/server.go +++ b/satellite/console/consoleweb/server.go @@ -776,8 +776,43 @@ func (server *Server) handleInvited(w http.ResponseWriter, r *http.Request) { return } - email := strings.ToLower(invite.Email) - http.Redirect(w, r, loginLink+"?email="+email, http.StatusTemporaryRedirect) + user, _, err := server.service.GetUserByEmailWithUnverified(ctx, invite.Email) + if err != nil && !console.ErrEmailNotFound.Has(err) { + server.log.Error("error getting invitation recipient", zap.Error(err)) + server.serveError(w, http.StatusInternalServerError) + return + } + if user != nil { + http.Redirect(w, r, loginLink+"?email="+user.Email, http.StatusTemporaryRedirect) + return + } + + params := url.Values{"email": {strings.ToLower(invite.Email)}} + + if invite.InviterID != nil { + inviter, err := server.service.GetUser(ctx, *invite.InviterID) + if err != nil { + server.log.Error("error getting invitation sender", zap.Error(err)) + server.serveError(w, http.StatusInternalServerError) + return + } + name := inviter.ShortName + if name == "" { + name = inviter.FullName + } + params.Add("inviter", name) + params.Add("inviter_email", inviter.Email) + } + + proj, err := server.service.GetProjectNoAuth(ctx, invite.ProjectID) + if err != nil { + server.log.Error("error getting invitation project", zap.Error(err)) + server.serveError(w, http.StatusInternalServerError) + return + } + params.Add("project", proj.Name) + + http.Redirect(w, r, server.config.ExternalAddress+"signup?"+params.Encode(), http.StatusTemporaryRedirect) } // graphqlHandler is graphql endpoint http handler function. diff --git a/satellite/console/consoleweb/server_test.go b/satellite/console/consoleweb/server_test.go index a9d67b520..a8313a66f 100644 --- a/satellite/console/consoleweb/server_test.go +++ b/satellite/console/consoleweb/server_test.go @@ -85,23 +85,15 @@ func TestInvitedRouting(t *testing.T) { }, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) { sat := planet.Satellites[0] service := sat.API.Console.Service + invitedEmail := "invited@mail.test" - user, err := sat.AddUser(ctx, console.CreateUser{ - FullName: "Test User", - Email: "u@mail.test", + owner, err := sat.AddUser(ctx, console.CreateUser{ + FullName: "Project Owner", + Email: "owner@mail.test", }, 1) require.NoError(t, err) - user2, err := sat.AddUser(ctx, console.CreateUser{ - FullName: "Test User2", - Email: "u2@mail.test", - }, 1) - require.NoError(t, err) - - ctx1, err := sat.UserContext(ctx, user.ID) - require.NoError(t, err) - - project, err := sat.AddProject(ctx1, user.ID, "Test Project") + project, err := sat.AddProject(ctx, owner.ID, "Test Project") require.NoError(t, err) client := http.Client{} @@ -128,24 +120,34 @@ func TestInvitedRouting(t *testing.T) { loginURL := baseURL + "login" invalidURL := loginURL + "?invite_invalid=true" - tokenInvalidProj, err := service.CreateInviteToken(ctx, project.ID, user2.Email, time.Now()) + tokenInvalidProj, err := service.CreateInviteToken(ctx, project.ID, invitedEmail, time.Now()) require.NoError(t, err) - token, err := service.CreateInviteToken(ctx, project.PublicID, user2.Email, time.Now()) + token, err := service.CreateInviteToken(ctx, project.PublicID, invitedEmail, time.Now()) require.NoError(t, err) checkInvitedRedirect("Invited - Invalid projectID", invalidURL, tokenInvalidProj) checkInvitedRedirect("Invited - User not invited", invalidURL, token) - _, err = service.InviteProjectMembers(ctx1, project.ID, []string{user2.Email}) + ownerCtx, err := sat.UserContext(ctx, owner.ID) + require.NoError(t, err) + _, err = service.InviteProjectMembers(ownerCtx, project.ID, []string{invitedEmail}) require.NoError(t, err) - token, err = service.CreateInviteToken(ctx, project.PublicID, user2.Email, time.Now()) + // Valid invite for nonexistent user should redirect to registration page with + // query parameters containing invitation information. + params := "email=invited%40mail.test&inviter=Project+Owner&inviter_email=owner%40mail.test&project=Test+Project" + checkInvitedRedirect("Invited - Nonexistent user", baseURL+"signup?"+params, token) + + invitedUser, err := sat.AddUser(ctx, console.CreateUser{ + FullName: "Invited User", + Email: invitedEmail, + }, 1) require.NoError(t, err) // valid invite should redirect to login page with email. - checkInvitedRedirect("Invited - User invited", loginURL+"?email="+user2.Email, token) + checkInvitedRedirect("Invited - User invited", loginURL+"?email="+invitedUser.Email, token) }) } diff --git a/satellite/console/service.go b/satellite/console/service.go index adf25bbf8..61ef3c32e 100644 --- a/satellite/console/service.go +++ b/satellite/console/service.go @@ -3578,8 +3578,8 @@ func (s *Service) InviteProjectMembers(ctx context.Context, projectID uuid.UUID, } projectID = isMember.project.ID - // collect user querying errors - users := make([]*User, 0) + var users []*User + var newUserEmails []string for _, email := range emails { invitedUser, err := s.store.Users().GetByEmail(ctx, email) if err == nil { @@ -3598,7 +3598,9 @@ func (s *Service) InviteProjectMembers(ctx context.Context, projectID uuid.UUID, return nil, ErrProjectInviteActive.New(projInviteActiveErrMsg, invitedUser.Email) } users = append(users, invitedUser) - } else if !errs.Is(err, sql.ErrNoRows) { + } else if errs.Is(err, sql.ErrNoRows) { + newUserEmails = append(newUserEmails, email) + } else { return nil, Error.Wrap(err) } } @@ -3606,20 +3608,20 @@ func (s *Service) InviteProjectMembers(ctx context.Context, projectID uuid.UUID, inviteTokens := make(map[string]string) // add project invites in transaction scope err = s.store.WithTx(ctx, func(ctx context.Context, tx DBTx) error { - for _, invited := range users { + for _, email := range emails { invite, err := tx.ProjectInvitations().Upsert(ctx, &ProjectInvitation{ ProjectID: projectID, - Email: invited.Email, + Email: email, InviterID: &user.ID, }) if err != nil { return err } - token, err := s.CreateInviteToken(ctx, isMember.project.PublicID, invited.Email, invite.CreatedAt) + token, err := s.CreateInviteToken(ctx, isMember.project.PublicID, email, invite.CreatedAt) if err != nil { return err } - inviteTokens[invited.Email] = token + inviteTokens[email] = token invites = append(invites, *invite) } return nil @@ -3646,6 +3648,18 @@ func (s *Service) InviteProjectMembers(ctx context.Context, projectID uuid.UUID, }, ) } + for _, email := range newUserEmails { + inviteLink := fmt.Sprintf("%s?invite=%s", baseLink, inviteTokens[email]) + s.mailService.SendRenderedAsync( + ctx, + []post.Address{{Address: email}}, + &NewUserProjectInvitationEmail{ + InviterEmail: user.Email, + Region: s.satelliteName, + SignUpLink: inviteLink, + }, + ) + } return invites, nil } diff --git a/satellite/console/service_test.go b/satellite/console/service_test.go index 6e71f2806..29fb1f025 100644 --- a/satellite/console/service_test.go +++ b/satellite/console/service_test.go @@ -2031,15 +2031,14 @@ func TestProjectInvitations(t *testing.T) { require.NoError(t, err) require.Len(t, invites, 1) - // adding in a non-existent user should not fail the invitation. + // adding in a non-existent user should work. invites, err = service.InviteProjectMembers(ctx, project.ID, []string{user3.Email, "notauser@mail.com"}) require.NoError(t, err) - require.Len(t, invites, 1) + require.Len(t, invites, 2) invites, err = service.GetUserProjectInvitations(ctx3) require.NoError(t, err) require.Len(t, invites, 1) - user3Invite := invites[0] // 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"}) @@ -2054,10 +2053,12 @@ func TestProjectInvitations(t *testing.T) { require.Empty(t, invites) // expire the invitation. - require.False(t, service.IsProjectInvitationExpired(&user3Invite)) + user3Invite, err := sat.DB.Console().ProjectInvitations().Get(ctx, project.ID, user3.Email) + require.NoError(t, err) + require.False(t, service.IsProjectInvitationExpired(user3Invite)) oldCreatedAt := user3Invite.CreatedAt - setInviteDate(t, ctx, &user3Invite, time.Now().Add(-sat.Config.Console.ProjectInvitationExpiration)) - require.True(t, service.IsProjectInvitationExpired(&user3Invite)) + setInviteDate(t, ctx, user3Invite, time.Now().Add(-sat.Config.Console.ProjectInvitationExpiration)) + require.True(t, service.IsProjectInvitationExpired(user3Invite)) // resending an expired invitation should succeed. invites, err = service.InviteProjectMembers(ctx2, project.ID, []string{user3.Email}) diff --git a/web/satellite/src/components/common/VInput.vue b/web/satellite/src/components/common/VInput.vue index 4a0c17a42..5ded35627 100644 --- a/web/satellite/src/components/common/VInput.vue +++ b/web/satellite/src/components/common/VInput.vue @@ -182,6 +182,11 @@ watch(() => props.initValue, (val, oldVal) => { onBeforeMount(() => { type.value = props.isPassword ? passwordType : textType; + + if (props.initValue) { + value.value = props.initValue; + emit('setData', props.initValue); + } }); diff --git a/web/satellite/src/views/registration/RegisterArea.vue b/web/satellite/src/views/registration/RegisterArea.vue index 00349d842..3d4d83fe2 100644 --- a/web/satellite/src/views/registration/RegisterArea.vue +++ b/web/satellite/src/views/registration/RegisterArea.vue @@ -47,11 +47,11 @@ class="register-area__input-area__container" :class="{ 'professional-container': isProfessional }" > -
+

Get 25 GB Free

-
+

@@ -89,7 +89,7 @@

-

+

{{ inviterName }} ({{ inviterEmail }}) has invited you to the project {{ projectName }} on Storj. Create an account on the {{ satelliteName }} region to join {{ inviterName }} in the project.

@@ -130,6 +130,8 @@ label="Email Address" max-symbols="72" placeholder="user@example.com" + :init-value="email" + :disabled="!!email" :error="emailError" role-description="email" @setData="setEmail" @@ -328,6 +330,7 @@ const viewConfig = ref(null); // DCS logic const secret = queryRef('token'); +const email = queryRef('email'); const inviterName = queryRef('inviter'); const inviterEmail = queryRef('inviter_email'); const projectName = queryRef('project'); @@ -418,6 +421,7 @@ function clickSatellite(address): void { * Toggles satellite selection dropdown visibility (Tardigrade). */ function toggleDropdown(): void { + if (isInvited.value) return; isDropdownShown.value = !isDropdownShown.value; } @@ -533,6 +537,14 @@ const partneredSatellites = computed((): PartneredSatellite[] => { }); }); +/** + * Returns whether the current URL's query parameters indicate that the user was + * redirected from a project invitation link. + */ +const isInvited = computed((): boolean => { + return !!inviterName.value && !!inviterEmail.value && !!projectName.value && !!email.value; +}); + /** * Indicates if satellite is in beta. */