satellite/console: send unverified user activation link in project invite

When an unverified user is sent a project invitation it contains a
registration link currently. Instead, send an activation link.

github issue: https://github.com/storj/storj/issues/6033

Change-Id: I54b88de8347a2532f7a85372c0c5e4df4bf4eb38
This commit is contained in:
Cameron 2023-08-28 16:01:11 -04:00
parent d6e0987dd9
commit a5b1c0432f
4 changed files with 327 additions and 7 deletions

View File

@ -70,6 +70,21 @@ func (email *ExistingUserProjectInvitationEmail) Subject() string {
return "You were invited to join a project on Storj"
}
// UnverifiedUserProjectInvitationEmail is mailservice template for project invitation email for unverified users.
type UnverifiedUserProjectInvitationEmail struct {
InviterEmail string
Region string
ActivationLink string
}
// Template returns email template name.
func (*UnverifiedUserProjectInvitationEmail) Template() string { return "UnverifiedUserInvite" }
// Subject gets email subject.
func (email *UnverifiedUserProjectInvitationEmail) Subject() string {
return "You were invited to join a project on Storj"
}
// NewUserProjectInvitationEmail is mailservice template for project invitation email for new users.
type NewUserProjectInvitationEmail struct {
InviterEmail string

View File

@ -3653,6 +3653,7 @@ func (s *Service) InviteProjectMembers(ctx context.Context, projectID uuid.UUID,
var users []*User
var newUserEmails []string
var unverifiedUsers []User
for _, email := range emails {
invite, err := s.store.ProjectInvitations().Get(ctx, projectID, email)
if err != nil && !errs.Is(err, sql.ErrNoRows) {
@ -3662,15 +3663,25 @@ func (s *Service) InviteProjectMembers(ctx context.Context, projectID uuid.UUID,
return nil, ErrAlreadyInvited.New(projInviteExistsErrMsg, email)
}
invitedUser, err := s.store.Users().GetByEmail(ctx, email)
invitedUser, unverified, err := s.store.Users().GetByEmailWithUnverified(ctx, email)
if err == nil {
_, err = s.isProjectMember(ctx, invitedUser.ID, projectID)
if err != nil && !ErrNoMembership.Has(err) {
return nil, Error.Wrap(err)
} else if err == nil {
return nil, ErrAlreadyMember.New("%s is already a member", email)
if invitedUser != nil {
_, err = s.isProjectMember(ctx, invitedUser.ID, projectID)
if err != nil && !ErrNoMembership.Has(err) {
return nil, Error.Wrap(err)
} else if err == nil {
return nil, ErrAlreadyMember.New("%s is already a member", email)
}
users = append(users, invitedUser)
} else {
oldest := User{}
for _, u := range unverified {
if u.CreatedAt.Before(oldest.CreatedAt) {
oldest = u
}
}
unverifiedUsers = append(unverifiedUsers, oldest)
}
users = append(users, invitedUser)
} else if errs.Is(err, sql.ErrNoRows) {
newUserEmails = append(newUserEmails, email)
} else {
@ -3690,6 +3701,19 @@ func (s *Service) InviteProjectMembers(ctx context.Context, projectID uuid.UUID,
if err != nil {
return err
}
var isUnverified bool
for _, u := range unverifiedUsers {
if email == u.Email {
isUnverified = true
invites = append(invites, *invite)
break
}
}
if isUnverified {
continue
}
token, err := s.CreateInviteToken(ctx, isMember.project.PublicID, email, invite.CreatedAt)
if err != nil {
return err
@ -3721,6 +3745,23 @@ func (s *Service) InviteProjectMembers(ctx context.Context, projectID uuid.UUID,
},
)
}
for _, u := range unverifiedUsers {
token, err := s.GenerateActivationToken(ctx, u.ID, u.Email)
if err != nil {
return nil, Error.Wrap(err)
}
activationLink := fmt.Sprintf("%s/activation?token=%s", s.satelliteAddress, token)
s.mailService.SendRenderedAsync(
ctx,
[]post.Address{{Address: u.Email}},
&UnverifiedUserProjectInvitationEmail{
InviterEmail: user.Email,
Region: s.satelliteName,
ActivationLink: activationLink,
},
)
}
for _, email := range newUserEmails {
inviteLink := fmt.Sprintf("%s?invite=%s", baseLink, inviteTokens[email])
s.mailService.SendRenderedAsync(

View File

@ -29,10 +29,12 @@ import (
"storj.io/common/testrand"
"storj.io/common/uuid"
"storj.io/storj/private/blockchain"
"storj.io/storj/private/post"
"storj.io/storj/private/testplanet"
"storj.io/storj/satellite"
"storj.io/storj/satellite/buckets"
"storj.io/storj/satellite/console"
"storj.io/storj/satellite/console/consoleweb/consoleapi"
"storj.io/storj/satellite/payments"
"storj.io/storj/satellite/payments/billing"
"storj.io/storj/satellite/payments/coinpayments"
@ -2099,6 +2101,23 @@ func TestServiceGenMethods(t *testing.T) {
})
}
type EmailVerifier struct {
Data consoleapi.ContextChannel
Context context.Context
}
func (v *EmailVerifier) SendEmail(ctx context.Context, msg *post.Message) error {
body := ""
for _, part := range msg.Parts {
body += part.Content
}
return v.Data.Send(v.Context, body)
}
func (v *EmailVerifier) FromAddress() post.Address {
return post.Address{}
}
func TestProjectInvitations(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1,
@ -2212,6 +2231,29 @@ func TestProjectInvitations(t *testing.T) {
// inviting a project member should fail.
_, err = service.InviteProjectMembers(ctx, project.ID, []string{user2.Email})
require.Error(t, err)
// test inviting unverified user.
sender := &EmailVerifier{Context: ctx}
sat.API.Mail.Service.Sender = sender
regToken, err := service.CreateRegToken(ctx, 1)
require.NoError(t, err)
unverified, err := service.CreateUser(ctx, console.CreateUser{
FullName: "test user",
Email: "test-unverified-email@test",
Password: "password",
}, regToken.Secret)
require.NoError(t, err)
require.Zero(t, unverified.Status)
invites, err = service.InviteProjectMembers(ctx, project.ID, []string{unverified.Email})
require.NoError(t, err)
require.Equal(t, unverified.Email, strings.ToLower(invites[0].Email))
body, err := sender.Data.Get(ctx)
require.NoError(t, err)
require.Contains(t, body, "/activation")
})
t.Run("get invitation", func(t *testing.T) {

View File

@ -0,0 +1,222 @@
<!DOCTYPE html
PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office"
style="width:100%;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;padding:0;Margin:0">
<head>
<meta charset="UTF-8">
<meta content="width=device-width, initial-scale=1" name="viewport">
<meta name="x-apple-disable-message-reformatting">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta content="telephone=no" name="format-detection">
<title>Project Invitation</title><!--[if (mso 16)]>
<style type="text/css">
a {
text-decoration: none;
}
</style>
<![endif]--><!--[if gte mso 9]>
<style>sup {
font-size: 100% !important;
}</style><![endif]--><!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG></o:AllowPNG>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<style type="text/css">
#outlook a {
padding: 0;
}
.es-button {
mso-style-priority: 100 !important;
text-decoration: none !important;
}
a[x-apple-data-detectors] {
color: inherit !important;
text-decoration: none !important;
font-size: inherit !important;
font-family: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
}
@media only screen and (max-width: 600px) {
p,
ul li,
ol li,
a {
line-height: 150% !important
}
h1,
h2,
h3,
h1 a,
h2 a,
h3 a {
line-height: 120% !important
}
h1 {
font-size: 30px !important;
text-align: center
}
h2 {
font-size: 26px !important;
text-align: center
}
h3 {
font-size: 20px !important;
text-align: center
}
.es-content-body h1 a {
font-size: 30px !important
}
.es-content-body h2 a {
font-size: 26px !important
}
.es-content-body h3 a {
font-size: 20px !important
}
.es-content-body p,
.es-content-body ul li,
.es-content-body ol li,
.es-content-body a {
font-size: 16px !important
}
.es-button-border {
display: block !important
}
a.es-button,
button.es-button {
font-size: 20px !important;
display: block !important;
padding: 15px 25px 15px 25px !important
}
.es-content table,
.es-content {
width: 100% !important;
max-width: 600px !important
}
.adapt-img {
width: 100% !important;
height: auto !important
}
}
</style>
</head>
<body
style="width:100%;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;font-family:'helvetica neue', helvetica, arial, sans-serif;padding:0;Margin:0">
<div class="es-wrapper-color" style="background-color:#F6F6F6"><!--[if gte mso 9]>
<v:background xmlns:v="urn:schemas-microsoft-com:vml" fill="t">
<v:fill type="tile" color="#f6f6f6"></v:fill>
</v:background>
<![endif]-->
<table class="es-wrapper" width="100%" cellspacing="0" cellpadding="0"
style="mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px;padding:0;Margin:0;width:100%;height:100%;background-repeat:repeat;background-position:center top;background-color:#F6F6F6">
<tr style="border-collapse:collapse">
<td valign="top" style="padding:0;Margin:0">
<table class="es-content" cellspacing="0" cellpadding="0" align="center"
style="mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px;table-layout:fixed !important;width:100%">
<tr style="border-collapse:collapse">
<td style="padding:0;Margin:0;background-color:#fafafb;background-size:cover"
bgcolor="#FAFAFB" align="center">
<table class="es-content-body"
style="mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px;background-color:transparent;width:640px"
cellspacing="0" cellpadding="0" bgcolor="#f6f6f6" align="center">
<tr style="border-collapse:collapse">
<td align="left"
style="padding:0;Margin:0;padding-top:20px;padding-left:20px;padding-right:20px">
<table width="100%" cellspacing="0" cellpadding="0"
style="mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px">
<tr style="border-collapse:collapse">
<td valign="top" align="center"
style="padding:0;Margin:0;width:600px">
<table width="100%" cellspacing="0" cellpadding="0"
role="presentation"
style="mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px">
<tr style="border-collapse:collapse">
<td align="center"
style="padding:0;Margin:0;padding-top:25px;padding-bottom:30px">
<h1
style="Margin:0;line-height:67px;mso-line-height-rule:exactly;font-family:'helvetica neue', helvetica, arial, sans-serif;font-size:56px;font-style:normal;font-weight:bold;color:#091c45">
You were invited<br>to a project on Storj</h1>
</td>
</tr>
<tr style="border-collapse:collapse">
<td esdev-links-color="#b7bdc9" align="center"
style="padding:0;Margin:0">
<p
style="Margin:0;-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;font-family:'helvetica neue', helvetica, arial, sans-serif;line-height:21px;color:#091c45;font-size:14px">
{{ .InviterEmail }} has invited you to
a project on Storj DCS on the {{ .Region }}
region. Click the link to activate your account,
then sign in to join the project. Do not share
this link with anyone.</p>
</td>
</tr>
<tr style="border-collapse:collapse">
<td align="center"
style="margin:0;padding: 25px 0 50px;">
<!--[if mso]>
<a href="{{ .ActivationLink }}" target="_blank"
hidden>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:w="urn:schemas-microsoft-com:office:word"
esdevVmlButton
href="{{ .ActivationLink }}"
style="height:46px; v-text-anchor:middle; width:195px"
arcsize="22%" stroke="f"
fillcolor="#00ac26">
<w:anchorlock></w:anchorlock>
<center style='color:#ffffff; font-family:"helvetica neue", helvetica, arial, sans-serif; font-size:14px; font-weight:400; line-height:14px; mso-text-raise:1px'>
Accept Invitation →
</center>
</v:roundrect>
</a>
<![endif]-->
<!--[if !mso]><!-->
<span class="msohide es-button-border"
style="box-shadow: 0px 20px 30px rgba(10, 27, 44, 0.2);border-style:solid;border-color:#75B6C9;background:#00ac26;border-width:0px;display:inline-block;border-radius:10px;width:auto;mso-hide:all">
<a href="{{ .ActivationLink }}"
class="es-button msohide" target="_blank"
style="mso-style-priority:100 !important;text-decoration:none;-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;color:#ffffff;font-size:14px;display:inline-block;background:#00ac26;border-radius:10px;font-family:'helvetica neue', helvetica, arial, sans-serif;font-weight:700;font-style:normal;line-height:17px;width:auto;text-align:center;padding:15px 25px 15px 25px;mso-padding-alt:0;mso-border-alt:10px solid #75B6C9;mso-hide:all">Activate
Account →</a>
</span>
<!--<![endif]-->
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
</body>
</html>