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
This commit is contained in:
Jeremy Wharton 2023-06-26 11:53:46 -05:00 committed by Storj Robot
parent a010459520
commit 706cd0b9fb
6 changed files with 105 additions and 36 deletions

View File

@ -776,8 +776,43 @@ func (server *Server) handleInvited(w http.ResponseWriter, r *http.Request) {
return return
} }
email := strings.ToLower(invite.Email) user, _, err := server.service.GetUserByEmailWithUnverified(ctx, invite.Email)
http.Redirect(w, r, loginLink+"?email="+email, http.StatusTemporaryRedirect) 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. // graphqlHandler is graphql endpoint http handler function.

View File

@ -85,23 +85,15 @@ func TestInvitedRouting(t *testing.T) {
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) { }, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
sat := planet.Satellites[0] sat := planet.Satellites[0]
service := sat.API.Console.Service service := sat.API.Console.Service
invitedEmail := "invited@mail.test"
user, err := sat.AddUser(ctx, console.CreateUser{ owner, err := sat.AddUser(ctx, console.CreateUser{
FullName: "Test User", FullName: "Project Owner",
Email: "u@mail.test", Email: "owner@mail.test",
}, 1) }, 1)
require.NoError(t, err) require.NoError(t, err)
user2, err := sat.AddUser(ctx, console.CreateUser{ project, err := sat.AddProject(ctx, owner.ID, "Test Project")
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")
require.NoError(t, err) require.NoError(t, err)
client := http.Client{} client := http.Client{}
@ -128,24 +120,34 @@ func TestInvitedRouting(t *testing.T) {
loginURL := baseURL + "login" loginURL := baseURL + "login"
invalidURL := loginURL + "?invite_invalid=true" 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) 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) require.NoError(t, err)
checkInvitedRedirect("Invited - Invalid projectID", invalidURL, tokenInvalidProj) checkInvitedRedirect("Invited - Invalid projectID", invalidURL, tokenInvalidProj)
checkInvitedRedirect("Invited - User not invited", invalidURL, token) 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) 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) require.NoError(t, err)
// valid invite should redirect to login page with email. // 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)
}) })
} }

View File

@ -3578,8 +3578,8 @@ func (s *Service) InviteProjectMembers(ctx context.Context, projectID uuid.UUID,
} }
projectID = isMember.project.ID projectID = isMember.project.ID
// collect user querying errors var users []*User
users := make([]*User, 0) var newUserEmails []string
for _, email := range emails { for _, email := range emails {
invitedUser, err := s.store.Users().GetByEmail(ctx, email) invitedUser, err := s.store.Users().GetByEmail(ctx, email)
if err == nil { if err == nil {
@ -3598,7 +3598,9 @@ func (s *Service) InviteProjectMembers(ctx context.Context, projectID uuid.UUID,
return nil, ErrProjectInviteActive.New(projInviteActiveErrMsg, invitedUser.Email) return nil, ErrProjectInviteActive.New(projInviteActiveErrMsg, invitedUser.Email)
} }
users = append(users, invitedUser) 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) return nil, Error.Wrap(err)
} }
} }
@ -3606,20 +3608,20 @@ func (s *Service) InviteProjectMembers(ctx context.Context, projectID uuid.UUID,
inviteTokens := make(map[string]string) inviteTokens := make(map[string]string)
// add project invites in transaction scope // add project invites in transaction scope
err = s.store.WithTx(ctx, func(ctx context.Context, tx DBTx) error { 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{ invite, err := tx.ProjectInvitations().Upsert(ctx, &ProjectInvitation{
ProjectID: projectID, ProjectID: projectID,
Email: invited.Email, Email: email,
InviterID: &user.ID, InviterID: &user.ID,
}) })
if err != nil { if err != nil {
return err 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 { if err != nil {
return err return err
} }
inviteTokens[invited.Email] = token inviteTokens[email] = token
invites = append(invites, *invite) invites = append(invites, *invite)
} }
return nil 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 return invites, nil
} }

View File

@ -2031,15 +2031,14 @@ func TestProjectInvitations(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.Len(t, invites, 1) 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"}) invites, err = service.InviteProjectMembers(ctx, project.ID, []string{user3.Email, "notauser@mail.com"})
require.NoError(t, err) require.NoError(t, err)
require.Len(t, invites, 1) require.Len(t, invites, 2)
invites, err = service.GetUserProjectInvitations(ctx3) invites, err = service.GetUserProjectInvitations(ctx3)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, invites, 1) require.Len(t, invites, 1)
user3Invite := invites[0]
// prevent unauthorized users from inviting others (user2 is not a member of the project yet). // 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"}) _, err = service.InviteProjectMembers(ctx2, project.ID, []string{"other@mail.com"})
@ -2054,10 +2053,12 @@ func TestProjectInvitations(t *testing.T) {
require.Empty(t, invites) require.Empty(t, invites)
// expire the invitation. // 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 oldCreatedAt := user3Invite.CreatedAt
setInviteDate(t, ctx, &user3Invite, time.Now().Add(-sat.Config.Console.ProjectInvitationExpiration)) setInviteDate(t, ctx, user3Invite, time.Now().Add(-sat.Config.Console.ProjectInvitationExpiration))
require.True(t, service.IsProjectInvitationExpired(&user3Invite)) require.True(t, service.IsProjectInvitationExpired(user3Invite))
// resending an expired invitation should succeed. // resending an expired invitation should succeed.
invites, err = service.InviteProjectMembers(ctx2, project.ID, []string{user3.Email}) invites, err = service.InviteProjectMembers(ctx2, project.ID, []string{user3.Email})

View File

@ -182,6 +182,11 @@ watch(() => props.initValue, (val, oldVal) => {
onBeforeMount(() => { onBeforeMount(() => {
type.value = props.isPassword ? passwordType : textType; type.value = props.isPassword ? passwordType : textType;
if (props.initValue) {
value.value = props.initValue;
emit('setData', props.initValue);
}
}); });
</script> </script>

View File

@ -47,11 +47,11 @@
class="register-area__input-area__container" class="register-area__input-area__container"
:class="{ 'professional-container': isProfessional }" :class="{ 'professional-container': isProfessional }"
> >
<div class="register-area__input-area__container__title-area" @click.stop="toggleDropdown"> <div class="register-area__input-area__container__title-area">
<div class="register-area__input-area__container__title-container"> <div class="register-area__input-area__container__title-container">
<h1 class="register-area__input-area__container__title-area__title">Get 25 GB Free</h1> <h1 class="register-area__input-area__container__title-area__title">Get 25 GB Free</h1>
</div> </div>
<div class="register-area__input-area__expand"> <div class="register-area__input-area__expand" @click.stop="toggleDropdown">
<div class="register-area__input-area__info-button"> <div class="register-area__input-area__info-button">
<InfoIcon /> <InfoIcon />
<p class="register-area__input-area__info-button__message"> <p class="register-area__input-area__info-button__message">
@ -89,7 +89,7 @@
</ul> </ul>
</div> </div>
</div> </div>
<p v-if="inviterName && inviterEmail && projectName" class="register-area__input-area__container__invitation-text"> <p v-if="isInvited" class="register-area__input-area__container__invitation-text">
{{ inviterName }} ({{ inviterEmail }}) has invited you to the project {{ projectName }} on Storj. Create an account on the {{ satelliteName }} region to join {{ inviterName }} in the project. {{ inviterName }} ({{ inviterEmail }}) has invited you to the project {{ projectName }} on Storj. Create an account on the {{ satelliteName }} region to join {{ inviterName }} in the project.
</p> </p>
<div class="register-area__input-area__toggle__container"> <div class="register-area__input-area__toggle__container">
@ -130,6 +130,8 @@
label="Email Address" label="Email Address"
max-symbols="72" max-symbols="72"
placeholder="user@example.com" placeholder="user@example.com"
:init-value="email"
:disabled="!!email"
:error="emailError" :error="emailError"
role-description="email" role-description="email"
@setData="setEmail" @setData="setEmail"
@ -328,6 +330,7 @@ const viewConfig = ref<ViewConfig | null>(null);
// DCS logic // DCS logic
const secret = queryRef('token'); const secret = queryRef('token');
const email = queryRef('email');
const inviterName = queryRef('inviter'); const inviterName = queryRef('inviter');
const inviterEmail = queryRef('inviter_email'); const inviterEmail = queryRef('inviter_email');
const projectName = queryRef('project'); const projectName = queryRef('project');
@ -418,6 +421,7 @@ function clickSatellite(address): void {
* Toggles satellite selection dropdown visibility (Tardigrade). * Toggles satellite selection dropdown visibility (Tardigrade).
*/ */
function toggleDropdown(): void { function toggleDropdown(): void {
if (isInvited.value) return;
isDropdownShown.value = !isDropdownShown.value; 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. * Indicates if satellite is in beta.
*/ */