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:
parent
a010459520
commit
706cd0b9fb
@ -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.
|
||||
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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})
|
||||
|
@ -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);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -47,11 +47,11 @@
|
||||
class="register-area__input-area__container"
|
||||
: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">
|
||||
<h1 class="register-area__input-area__container__title-area__title">Get 25 GB Free</h1>
|
||||
</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">
|
||||
<InfoIcon />
|
||||
<p class="register-area__input-area__info-button__message">
|
||||
@ -89,7 +89,7 @@
|
||||
</ul>
|
||||
</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.
|
||||
</p>
|
||||
<div class="register-area__input-area__toggle__container">
|
||||
@ -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<ViewConfig | null>(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.
|
||||
*/
|
||||
|
Loading…
Reference in New Issue
Block a user