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
|
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.
|
||||||
|
@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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})
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
Loading…
Reference in New Issue
Block a user