mirror of https://github.com/grafana/grafana
Auth: Passwordless Login Option Using Magic Links (#95436)
* initial passwordless client * passwordless login page * Working basic e2e flow * Add todo comments * Improve the passwordless login flow * improved passwordless login, backend for passwordless signup * add expiration to emails * update email templates & render username & name fields on signup * improve email templates * change login page text while awaiting passwordless code * fix merge conflicts * use claims.TypeUser * add initial passwordless tests * better error messages * simplified error name * remove completed TODOs * linting & minor test improvements & rename passwordless routes * more linting fixes * move code generation to its own func, use locationService to get query params * fix ampersand in email templates & use passwordless api routes in LoginCtrl * txt emails more closely match html email copy * move passwordless auth behind experimental feature toggle * fix PasswordlessLogin property failing typecheck * make update-workspace * user correct placeholder * Update emails/templates/passwordless_verify_existing_user.txt Co-authored-by: Dan Cech <dcech@grafana.com> * Update emails/templates/passwordless_verify_existing_user.mjml Co-authored-by: Dan Cech <dcech@grafana.com> * Update emails/templates/passwordless_verify_new_user.txt Co-authored-by: Dan Cech <dcech@grafana.com> * Update emails/templates/passwordless_verify_new_user.txt Co-authored-by: Dan Cech <dcech@grafana.com> * Update emails/templates/passwordless_verify_new_user.mjml Co-authored-by: Dan Cech <dcech@grafana.com> * use & in email templates * Update emails/templates/passwordless_verify_existing_user.txt Co-authored-by: Dan Cech <dcech@grafana.com> * remove IP address validation * struct for passwordless settings * revert go.work.sum changes * mock locationService.getSearch in failing test --------- Co-authored-by: Mihaly Gyongyosi <mgyongyosi@users.noreply.github.com> Co-authored-by: Dan Cech <dcech@grafana.com>pull/96460/head
parent
c865958292
commit
6abe99efd6
@ -0,0 +1,51 @@ |
|||||||
|
<mjml> |
||||||
|
<!-- global variables --> |
||||||
|
<mj-include path="./partials/_globals.mjml" /> |
||||||
|
<!-- css styling --> |
||||||
|
<mj-include path="./partials/layout/theme.css" type="css" css-inline="inline" /> |
||||||
|
<mj-head> |
||||||
|
<!-- ⬇ Don't forget to specify an email subject below! ⬇ --> |
||||||
|
<mj-title> {{ Subject .Subject .TemplateData "Verify your email" }} </mj-title> |
||||||
|
<mj-include path="./partials/layout/head.mjml" /> |
||||||
|
</mj-head> |
||||||
|
<mj-body> |
||||||
|
<mj-section> |
||||||
|
<mj-include path="./partials/layout/header.mjml" /> |
||||||
|
</mj-section> |
||||||
|
<mj-wrapper css-class="background" padding="0"> |
||||||
|
<mj-section padding="0"> |
||||||
|
<mj-column> |
||||||
|
<mj-text> |
||||||
|
<h2>Please verify your email</h2> |
||||||
|
</mj-text> |
||||||
|
<mj-text> |
||||||
|
Copy and paste the confirmation code into the login form to verify your email address. This confirmation code |
||||||
|
will expire in {{ .Expire }} minutes. |
||||||
|
</mj-text> |
||||||
|
</mj-column> |
||||||
|
</mj-section> |
||||||
|
<mj-section padding="10px 25px"> |
||||||
|
<mj-column css-class="well"> |
||||||
|
<mj-text font-size="22px" font-weight="bold" align="center"> {{ .ConfirmationCode }} </mj-text> |
||||||
|
</mj-column> |
||||||
|
</mj-section> |
||||||
|
<mj-section padding="0"> |
||||||
|
<mj-column> |
||||||
|
<mj-text> Alternatively, you can use the button below to verify your email address. </mj-text> |
||||||
|
<mj-button href="{{ .AppUrl }}login/?code={{ .Code }}&confirmationCode={{ .ConfirmationCode }}"> |
||||||
|
Verify your email |
||||||
|
</mj-button> |
||||||
|
<mj-text> You can also copy and paste this link into your browser directly: </mj-text> |
||||||
|
<mj-text> |
||||||
|
<a rel="noopener" href="{{ .AppUrl }}login?code={{ .Code }}&confirmationCode={{ .ConfirmationCode }}" |
||||||
|
>{{ .AppUrl }}login?code={{ .Code }}&confirmationCode={{ .ConfirmationCode }}</a |
||||||
|
> |
||||||
|
</mj-text> |
||||||
|
</mj-column> |
||||||
|
</mj-section> |
||||||
|
</mj-wrapper> |
||||||
|
<mj-section> |
||||||
|
<mj-include path="./partials/layout/footer.mjml" /> |
||||||
|
</mj-section> |
||||||
|
</mj-body> |
||||||
|
</mjml> |
||||||
@ -0,0 +1,10 @@ |
|||||||
|
[[HiddenSubject .Subject "Verify your email"]] |
||||||
|
|
||||||
|
Hi, |
||||||
|
|
||||||
|
Copy and paste the email verification code: |
||||||
|
[[.ConfirmationCode]] |
||||||
|
into the login form to verify your email address. This confirmation code will expire in {{ .Expire }} minutes. |
||||||
|
Alternatively, you can use the button below to verify your email address. |
||||||
|
|
||||||
|
[[.AppUrl]]login/?code=[[.Code]]&confirmationCode=[[.ConfirmationCode]] |
||||||
@ -0,0 +1,53 @@ |
|||||||
|
<mjml> |
||||||
|
<!-- global variables --> |
||||||
|
<mj-include path="./partials/_globals.mjml" /> |
||||||
|
<!-- css styling --> |
||||||
|
<mj-include path="./partials/layout/theme.css" type="css" css-inline="inline" /> |
||||||
|
<mj-head> |
||||||
|
<!-- ⬇ Don't forget to specify an email subject below! ⬇ --> |
||||||
|
<mj-title> {{ Subject .Subject .TemplateData "Welcome to Grafana, please complete your sign up!" }} </mj-title> |
||||||
|
<mj-include path="./partials/layout/head.mjml" /> |
||||||
|
</mj-head> |
||||||
|
<mj-body> |
||||||
|
<mj-section> |
||||||
|
<mj-include path="./partials/layout/header.mjml" /> |
||||||
|
</mj-section> |
||||||
|
<mj-wrapper css-class="background" padding="0"> |
||||||
|
<mj-section padding="0"> |
||||||
|
<mj-column> |
||||||
|
<mj-text> |
||||||
|
<h2>Please complete your signup</h2> |
||||||
|
</mj-text> |
||||||
|
<mj-text> |
||||||
|
Copy and paste the confirmation code into the sign up form to verify your email address. This confirmation |
||||||
|
code will expire in {{ .Expire }} minutes. |
||||||
|
</mj-text> |
||||||
|
</mj-column> |
||||||
|
</mj-section> |
||||||
|
<mj-section padding="10px 25px"> |
||||||
|
<mj-column css-class="well"> |
||||||
|
<mj-text font-size="22px" font-weight="bold" align="center"> {{ .ConfirmationCode }} </mj-text> |
||||||
|
</mj-column> |
||||||
|
</mj-section> |
||||||
|
<mj-section padding="0"> |
||||||
|
<mj-column> |
||||||
|
<mj-text> Alternatively, you can use the button below to complete your sign up. </mj-text> |
||||||
|
<mj-button href="{{ .AppUrl }}login/?code={{ .Code }}&confirmationCode={{ .ConfirmationCode }}&signup=true"> |
||||||
|
Complete Sign Up |
||||||
|
</mj-button> |
||||||
|
<mj-text> You can also copy and paste this link into your browser directly: </mj-text> |
||||||
|
<mj-text> |
||||||
|
<a |
||||||
|
rel="noopener" |
||||||
|
href="{{ .AppUrl }}login?code={{ .Code }}&confirmationCode={{ .ConfirmationCode }}&signup=true" |
||||||
|
>{{ .AppUrl }}login?code={{ .Code }}&confirmationCode={{ .ConfirmationCode }}&signup=true</a |
||||||
|
> |
||||||
|
</mj-text> |
||||||
|
</mj-column> |
||||||
|
</mj-section> |
||||||
|
</mj-wrapper> |
||||||
|
<mj-section> |
||||||
|
<mj-include path="./partials/layout/footer.mjml" /> |
||||||
|
</mj-section> |
||||||
|
</mj-body> |
||||||
|
</mjml> |
||||||
@ -0,0 +1,10 @@ |
|||||||
|
[[HiddenSubject .Subject "Welcome to Grafana, please complete your signup!"]] |
||||||
|
|
||||||
|
Hi, |
||||||
|
|
||||||
|
Copy and paste the email verification code: |
||||||
|
[[.ConfirmationCode]] |
||||||
|
into the sign up form to verify your email address. This confirmation code will expire in {{ .Expire }} minutes. |
||||||
|
Alternatively, you can use the button below to verify your email address. |
||||||
|
|
||||||
|
[[.AppUrl]]login/?code=[[.Code]]&confirmationCode=[[.ConfirmationCode]] |
||||||
@ -0,0 +1,335 @@ |
|||||||
|
package clients |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"crypto/subtle" |
||||||
|
"encoding/json" |
||||||
|
"errors" |
||||||
|
"fmt" |
||||||
|
"strconv" |
||||||
|
"time" |
||||||
|
|
||||||
|
"github.com/grafana/authlib/claims" |
||||||
|
"github.com/grafana/grafana/pkg/apimachinery/errutil" |
||||||
|
"github.com/grafana/grafana/pkg/infra/log" |
||||||
|
"github.com/grafana/grafana/pkg/infra/remotecache" |
||||||
|
"github.com/grafana/grafana/pkg/services/authn" |
||||||
|
"github.com/grafana/grafana/pkg/services/login" |
||||||
|
"github.com/grafana/grafana/pkg/services/loginattempt" |
||||||
|
"github.com/grafana/grafana/pkg/services/notifications" |
||||||
|
tempuser "github.com/grafana/grafana/pkg/services/temp_user" |
||||||
|
"github.com/grafana/grafana/pkg/services/user" |
||||||
|
"github.com/grafana/grafana/pkg/setting" |
||||||
|
"github.com/grafana/grafana/pkg/util" |
||||||
|
"github.com/grafana/grafana/pkg/web" |
||||||
|
) |
||||||
|
|
||||||
|
var ( |
||||||
|
errPasswordlessClientInvalidConfirmationCode = errutil.Unauthorized("passwordless.invalid.confirmation-code", errutil.WithPublicMessage("Invalid confirmation code")) |
||||||
|
errPasswordlessClientTooManyLoginAttempts = errutil.Unauthorized("passwordless.invalid.login-attempt", errutil.WithPublicMessage("Login temporarily blocked")) |
||||||
|
errPasswordlessClientInvalidEmail = errutil.Unauthorized("passwordless.invalid.email", errutil.WithPublicMessage("Invalid email")) |
||||||
|
errPasswordlessClientCodeAlreadySent = errutil.Unauthorized("passwordless.invalid.code", errutil.WithPublicMessage("Code already sent to email")) |
||||||
|
|
||||||
|
errPasswordlessClientInternal = errutil.Internal("passwordless.failed", errutil.WithPublicMessage("An internal error occurred in the Passwordless client")) |
||||||
|
|
||||||
|
errPasswordlessClientMissingCode = errutil.BadRequest("passwordless.missing.code", errutil.WithPublicMessage("Missing code")) |
||||||
|
) |
||||||
|
|
||||||
|
const passwordlessKeyPrefix = "passwordless-%s" |
||||||
|
|
||||||
|
var _ authn.RedirectClient = new(Passwordless) |
||||||
|
|
||||||
|
func ProvidePasswordless(cfg *setting.Cfg, loginAttempts loginattempt.Service, userService user.Service, tempUserService tempuser.Service, notificationService notifications.Service, cache remotecache.CacheStorage) *Passwordless { |
||||||
|
return &Passwordless{cfg, loginAttempts, userService, tempUserService, notificationService, cache, log.New("authn.passwordless")} |
||||||
|
} |
||||||
|
|
||||||
|
type PasswordlessCacheCodeEntry struct { |
||||||
|
Email string `json:"email"` |
||||||
|
ConfirmationCode string `json:"confirmation_code"` |
||||||
|
SentDate string `json:"sent_date"` |
||||||
|
} |
||||||
|
|
||||||
|
type PasswordlessCacheEmailEntry struct { |
||||||
|
Code string `json:"code"` |
||||||
|
SentDate string `json:"sent_date"` |
||||||
|
} |
||||||
|
|
||||||
|
type Passwordless struct { |
||||||
|
cfg *setting.Cfg |
||||||
|
loginAttempts loginattempt.Service |
||||||
|
userService user.Service |
||||||
|
tempUserService tempuser.Service |
||||||
|
notificationService notifications.Service |
||||||
|
cache remotecache.CacheStorage |
||||||
|
log log.Logger |
||||||
|
} |
||||||
|
|
||||||
|
type EmailForm struct { |
||||||
|
Email string `json:"email" binding:"required,email"` |
||||||
|
} |
||||||
|
|
||||||
|
type PasswordlessForm struct { |
||||||
|
Code string `json:"code" binding:"required"` |
||||||
|
ConfirmationCode string `json:"confirmationCode" binding:"required"` |
||||||
|
Name string `json:"name"` |
||||||
|
Username string `json:"username"` |
||||||
|
} |
||||||
|
|
||||||
|
// Authenticate implements authn.Client.
|
||||||
|
func (c *Passwordless) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) { |
||||||
|
var form PasswordlessForm |
||||||
|
if err := web.Bind(r.HTTPRequest, &form); err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
return c.authenticatePasswordless(ctx, r, form) |
||||||
|
} |
||||||
|
|
||||||
|
func (c *Passwordless) generateCodes() (string, string, error) { |
||||||
|
alphabet := []byte("BCDFGHJKLMNPQRSTVWXZ") |
||||||
|
confirmationCode, err := util.GetRandomString(8, alphabet...) |
||||||
|
if err != nil { |
||||||
|
return "", "", err |
||||||
|
} |
||||||
|
code, err := util.GetRandomString(32) |
||||||
|
if err != nil { |
||||||
|
return "", "", err |
||||||
|
} |
||||||
|
return confirmationCode, code, err |
||||||
|
} |
||||||
|
|
||||||
|
// RedirectURL implements authn.RedirectClient.
|
||||||
|
func (c *Passwordless) RedirectURL(ctx context.Context, r *authn.Request) (*authn.Redirect, error) { |
||||||
|
var form EmailForm |
||||||
|
if err := web.Bind(r.HTTPRequest, &form); err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
// TODO: add IP address validation
|
||||||
|
ok, err := c.loginAttempts.Validate(ctx, form.Email) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
if !ok { |
||||||
|
return nil, errPasswordlessClientTooManyLoginAttempts.Errorf("too many consecutive incorrect login attempts for user - login for user temporarily blocked") |
||||||
|
} |
||||||
|
|
||||||
|
err = c.loginAttempts.Add(ctx, form.Email, web.RemoteAddr(r.HTTPRequest)) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
code, err := c.startPasswordless(ctx, form.Email) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
return &authn.Redirect{ |
||||||
|
URL: c.cfg.AppSubURL + "/login?code=" + code, |
||||||
|
Extra: map[string]string{"code": code}, |
||||||
|
}, nil |
||||||
|
} |
||||||
|
|
||||||
|
func (c *Passwordless) IsEnabled() bool { |
||||||
|
return true |
||||||
|
} |
||||||
|
|
||||||
|
func (c *Passwordless) Name() string { |
||||||
|
return authn.ClientPasswordless |
||||||
|
} |
||||||
|
|
||||||
|
func (c *Passwordless) startPasswordless(ctx context.Context, email string) (string, error) { |
||||||
|
// 1. check if is existing user with email or user invite with email
|
||||||
|
var existingUser *user.User |
||||||
|
var tempUsers []*tempuser.TempUserDTO |
||||||
|
var err error |
||||||
|
|
||||||
|
if !util.IsEmail(email) { |
||||||
|
return "", errPasswordlessClientInvalidEmail.Errorf("invalid email %s", email) |
||||||
|
} |
||||||
|
|
||||||
|
cacheKey := fmt.Sprintf(passwordlessKeyPrefix, email) |
||||||
|
_, err = c.cache.Get(ctx, cacheKey) |
||||||
|
if err != nil && !errors.Is(err, remotecache.ErrCacheItemNotFound) { |
||||||
|
return "", errPasswordlessClientInternal.Errorf("cache error: %s", err) |
||||||
|
} |
||||||
|
|
||||||
|
// if code already sent to email, return error
|
||||||
|
if err == nil { |
||||||
|
return "", errPasswordlessClientCodeAlreadySent.Errorf("passwordless code already sent to email %s", email) |
||||||
|
} |
||||||
|
|
||||||
|
existingUser, err = c.userService.GetByEmail(ctx, &user.GetUserByEmailQuery{Email: email}) |
||||||
|
if err != nil && !errors.Is(err, user.ErrUserNotFound) { |
||||||
|
return "", errPasswordlessClientInternal.Errorf("error retreiving user by email: %w - email: %s", err, email) |
||||||
|
} |
||||||
|
|
||||||
|
if existingUser == nil { |
||||||
|
tempUsers, err = c.tempUserService.GetTempUsersQuery(ctx, &tempuser.GetTempUsersQuery{Email: email, Status: tempuser.TmpUserInvitePending}) |
||||||
|
|
||||||
|
if err != nil && !errors.Is(err, tempuser.ErrTempUserNotFound) { |
||||||
|
return "", err |
||||||
|
} |
||||||
|
if tempUsers == nil { |
||||||
|
return "", errPasswordlessClientInvalidEmail.Errorf("no user or invite found with email %s", email) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// 2. if existing user or temp user found, send email with passwordless link
|
||||||
|
confirmationCode, code, err := c.generateCodes() |
||||||
|
if err != nil { |
||||||
|
return "", err |
||||||
|
} |
||||||
|
|
||||||
|
emailCmd := notifications.SendEmailCommand{ |
||||||
|
To: []string{email}, |
||||||
|
Data: map[string]any{ |
||||||
|
"Email": email, |
||||||
|
"ConfirmationCode": confirmationCode, |
||||||
|
"Code": code, |
||||||
|
"Expire": c.cfg.PasswordlessMagicLinkAuth.CodeExpiration.Minutes(), |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
if existingUser != nil { |
||||||
|
emailCmd.Template = "passwordless_verify_existing_user" |
||||||
|
} else { |
||||||
|
emailCmd.Template = "passwordless_verify_new_user" |
||||||
|
} |
||||||
|
|
||||||
|
err = c.notificationService.SendEmailCommandHandler(ctx, &emailCmd) |
||||||
|
if err != nil { |
||||||
|
return "", err |
||||||
|
} |
||||||
|
|
||||||
|
sentDate := time.Now().Format(time.RFC3339) |
||||||
|
|
||||||
|
value := &PasswordlessCacheCodeEntry{ |
||||||
|
Email: email, |
||||||
|
ConfirmationCode: confirmationCode, |
||||||
|
SentDate: sentDate, |
||||||
|
} |
||||||
|
valueBytes, err := json.Marshal(value) |
||||||
|
if err != nil { |
||||||
|
return "", err |
||||||
|
} |
||||||
|
|
||||||
|
cacheKey = fmt.Sprintf(passwordlessKeyPrefix, code) |
||||||
|
err = c.cache.Set(ctx, cacheKey, valueBytes, c.cfg.PasswordlessMagicLinkAuth.CodeExpiration) |
||||||
|
if err != nil { |
||||||
|
return "", errPasswordlessClientInternal.Errorf("cache error: %s", err) |
||||||
|
} |
||||||
|
|
||||||
|
// second cache entry to lookup code by email
|
||||||
|
emailValue := &PasswordlessCacheEmailEntry{ |
||||||
|
Code: code, |
||||||
|
SentDate: sentDate, |
||||||
|
} |
||||||
|
valueBytes, err = json.Marshal(emailValue) |
||||||
|
if err != nil { |
||||||
|
return "", err |
||||||
|
} |
||||||
|
|
||||||
|
cacheKey = fmt.Sprintf(passwordlessKeyPrefix, email) |
||||||
|
err = c.cache.Set(ctx, cacheKey, valueBytes, c.cfg.PasswordlessMagicLinkAuth.CodeExpiration) |
||||||
|
if err != nil { |
||||||
|
return "", errPasswordlessClientInternal.Errorf("cache error: %s", err) |
||||||
|
} |
||||||
|
|
||||||
|
return code, nil |
||||||
|
} |
||||||
|
|
||||||
|
func (c *Passwordless) authenticatePasswordless(ctx context.Context, r *authn.Request, form PasswordlessForm) (*authn.Identity, error) { |
||||||
|
code := form.Code |
||||||
|
confirmationCode := form.ConfirmationCode |
||||||
|
|
||||||
|
if len(code) == 0 || len(confirmationCode) == 0 { |
||||||
|
return nil, errPasswordlessClientMissingCode.Errorf("no code provided") |
||||||
|
} |
||||||
|
|
||||||
|
cacheKey := fmt.Sprintf(passwordlessKeyPrefix, code) |
||||||
|
jsonData, err := c.cache.Get(ctx, cacheKey) |
||||||
|
if err != nil { |
||||||
|
return nil, errPasswordlessClientInternal.Errorf("cache error: %s", err) |
||||||
|
} |
||||||
|
|
||||||
|
var codeEntry PasswordlessCacheCodeEntry |
||||||
|
err = json.Unmarshal(jsonData, &codeEntry) |
||||||
|
if err != nil { |
||||||
|
return nil, errPasswordlessClientInternal.Errorf("failed to parse entry from passwordless cache: %w - entry: %s", err, string(jsonData)) |
||||||
|
} |
||||||
|
|
||||||
|
if subtle.ConstantTimeCompare([]byte(codeEntry.ConfirmationCode), []byte(confirmationCode)) != 1 { |
||||||
|
return nil, errPasswordlessClientInvalidConfirmationCode |
||||||
|
} |
||||||
|
|
||||||
|
ok, err := c.loginAttempts.Validate(ctx, codeEntry.Email) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
if !ok { |
||||||
|
return nil, errPasswordlessClientTooManyLoginAttempts.Errorf("too many consecutive incorrect login attempts for user - login for user temporarily blocked") |
||||||
|
} |
||||||
|
|
||||||
|
if err := c.loginAttempts.Reset(ctx, codeEntry.Email); err != nil { |
||||||
|
c.log.Warn("could not reset login attempts", "err", err, "username", codeEntry.Email) |
||||||
|
} |
||||||
|
|
||||||
|
usr, err := c.userService.GetByEmail(ctx, &user.GetUserByEmailQuery{Email: codeEntry.Email}) |
||||||
|
if err != nil && !errors.Is(err, user.ErrUserNotFound) { |
||||||
|
return nil, errPasswordlessClientInternal.Errorf("error retreiving user by email: %w - email: %s", err, codeEntry.Email) |
||||||
|
} |
||||||
|
|
||||||
|
if usr == nil { |
||||||
|
tempUsers, err := c.tempUserService.GetTempUsersQuery(ctx, &tempuser.GetTempUsersQuery{Email: codeEntry.Email, Status: tempuser.TmpUserInvitePending}) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
if tempUsers == nil { |
||||||
|
return nil, errPasswordlessClientInvalidEmail.Errorf("no user or invite found with email %s", codeEntry.Email) |
||||||
|
} |
||||||
|
|
||||||
|
createUserCmd := user.CreateUserCommand{ |
||||||
|
Email: codeEntry.Email, |
||||||
|
Login: form.Username, |
||||||
|
Name: form.Name, |
||||||
|
} |
||||||
|
|
||||||
|
// TODO: use user sync hook to create user
|
||||||
|
usr, err = c.userService.Create(ctx, &createUserCmd) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
for _, tempUser := range tempUsers { |
||||||
|
if err := c.tempUserService.UpdateTempUserStatus(ctx, &tempuser.UpdateTempUserStatusCommand{Code: tempUser.Code, Status: tempuser.TmpUserCompleted}); err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// delete cache entry with code as key
|
||||||
|
err = c.cache.Delete(ctx, cacheKey) |
||||||
|
if err != nil { |
||||||
|
return nil, errPasswordlessClientInternal.Errorf("failed to delete entry from passwordless cache: %w - key: %s", err, cacheKey) |
||||||
|
} |
||||||
|
|
||||||
|
// delete cache entry with email as key
|
||||||
|
cacheKey = fmt.Sprintf(passwordlessKeyPrefix, codeEntry.Email) |
||||||
|
err = c.cache.Delete(ctx, cacheKey) |
||||||
|
if err != nil { |
||||||
|
return nil, errPasswordlessClientInternal.Errorf("failed to delete entry from passwordless cache: %w - key: %s", err, cacheKey) |
||||||
|
} |
||||||
|
|
||||||
|
// user was found so set auth module in req metadata
|
||||||
|
r.SetMeta(authn.MetaKeyAuthModule, login.PasswordlessAuthModule) |
||||||
|
|
||||||
|
return &authn.Identity{ |
||||||
|
ID: strconv.FormatInt(usr.ID, 10), |
||||||
|
Type: claims.TypeUser, |
||||||
|
OrgID: r.OrgID, |
||||||
|
ClientParams: authn.ClientParams{FetchSyncedUser: true, SyncPermissions: true}, |
||||||
|
AuthenticatedBy: login.PasswordlessAuthModule, |
||||||
|
}, nil |
||||||
|
} |
||||||
@ -0,0 +1,160 @@ |
|||||||
|
package clients |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"testing" |
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert" |
||||||
|
|
||||||
|
"github.com/grafana/authlib/claims" |
||||||
|
"github.com/grafana/grafana/pkg/infra/remotecache" |
||||||
|
"github.com/grafana/grafana/pkg/services/authn" |
||||||
|
"github.com/grafana/grafana/pkg/services/login" |
||||||
|
"github.com/grafana/grafana/pkg/services/loginattempt/loginattempttest" |
||||||
|
"github.com/grafana/grafana/pkg/services/notifications" |
||||||
|
tempuser "github.com/grafana/grafana/pkg/services/temp_user" |
||||||
|
"github.com/grafana/grafana/pkg/services/temp_user/tempusertest" |
||||||
|
"github.com/grafana/grafana/pkg/services/user" |
||||||
|
"github.com/grafana/grafana/pkg/services/user/usertest" |
||||||
|
"github.com/grafana/grafana/pkg/setting" |
||||||
|
"github.com/grafana/grafana/pkg/util" |
||||||
|
) |
||||||
|
|
||||||
|
func TestPasswordless_StartPasswordless(t *testing.T) { |
||||||
|
type testCase struct { |
||||||
|
desc string |
||||||
|
email string |
||||||
|
findUser bool |
||||||
|
findTempUser bool |
||||||
|
blockLogin bool |
||||||
|
expectedErr error |
||||||
|
} |
||||||
|
|
||||||
|
tests := []testCase{ |
||||||
|
{ |
||||||
|
desc: "should succeed if user is found", |
||||||
|
email: "user@domain.com", |
||||||
|
findUser: true, |
||||||
|
blockLogin: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
desc: "should succeed if temp user is found", |
||||||
|
email: "user@domain.com", |
||||||
|
findUser: false, |
||||||
|
findTempUser: true, |
||||||
|
blockLogin: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
desc: "should fail if user or temp user is not found", |
||||||
|
email: "user@domain.com", |
||||||
|
findUser: false, |
||||||
|
findTempUser: false, |
||||||
|
blockLogin: false, |
||||||
|
expectedErr: errPasswordlessClientInvalidEmail.Errorf("no user or invite found with email user@domain.com"), |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
for _, tt := range tests { |
||||||
|
t.Run(tt.desc, func(t *testing.T) { |
||||||
|
hashed, _ := util.EncodePassword("password", "salt") |
||||||
|
userService := &usertest.FakeUserService{ |
||||||
|
ExpectedUser: &user.User{ID: 1, Email: "user@domain.com", Login: "user", Password: user.Password(hashed), Salt: "salt"}, |
||||||
|
} |
||||||
|
las := &loginattempttest.FakeLoginAttemptService{ExpectedValid: !tt.blockLogin} |
||||||
|
tus := &tempusertest.FakeTempUserService{} |
||||||
|
tus.GetTempUsersQueryFN = func(ctx context.Context, query *tempuser.GetTempUsersQuery) ([]*tempuser.TempUserDTO, error) { |
||||||
|
return []*tempuser.TempUserDTO{{ |
||||||
|
ID: 1, |
||||||
|
Email: "user@domain.com", |
||||||
|
Status: tempuser.TmpUserInvitePending, |
||||||
|
EmailSent: true, |
||||||
|
}}, nil |
||||||
|
} |
||||||
|
ns := notifications.MockNotificationService() |
||||||
|
cache := remotecache.NewFakeCacheStorage() |
||||||
|
|
||||||
|
if !tt.findUser { |
||||||
|
userService.ExpectedUser = nil |
||||||
|
userService.ExpectedError = user.ErrUserNotFound |
||||||
|
} |
||||||
|
|
||||||
|
if !tt.findTempUser { |
||||||
|
tus.GetTempUsersQueryFN = func(ctx context.Context, query *tempuser.GetTempUsersQuery) ([]*tempuser.TempUserDTO, error) { |
||||||
|
return nil, tempuser.ErrTempUserNotFound |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
c := ProvidePasswordless(setting.NewCfg(), las, userService, tus, ns, cache) |
||||||
|
_, err := c.startPasswordless(context.Background(), tt.email) |
||||||
|
assert.ErrorIs(t, err, tt.expectedErr) |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func TestPasswordless_AuthenticatePasswordless(t *testing.T) { |
||||||
|
type testCase struct { |
||||||
|
desc string |
||||||
|
email string |
||||||
|
findUser bool |
||||||
|
blockLogin bool |
||||||
|
expectedErr error |
||||||
|
expectedIdentity *authn.Identity |
||||||
|
} |
||||||
|
|
||||||
|
tests := []testCase{ |
||||||
|
{ |
||||||
|
desc: "should successfully authenticate user with correct passwordless magic link", |
||||||
|
email: "user@domain.com", |
||||||
|
findUser: true, |
||||||
|
blockLogin: false, |
||||||
|
expectedIdentity: &authn.Identity{ |
||||||
|
ID: "1", |
||||||
|
Type: claims.TypeUser, |
||||||
|
OrgID: 1, |
||||||
|
AuthenticatedBy: login.PasswordlessAuthModule, |
||||||
|
ClientParams: authn.ClientParams{FetchSyncedUser: true, SyncPermissions: true}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
desc: "should fail if login is blocked", |
||||||
|
email: "user@domain.com", |
||||||
|
findUser: true, |
||||||
|
blockLogin: true, |
||||||
|
expectedErr: errPasswordlessClientTooManyLoginAttempts.Errorf("too many consecutive incorrect login attempts for user - login for user temporarily blocked"), |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
for _, tt := range tests { |
||||||
|
t.Run(tt.desc, func(t *testing.T) { |
||||||
|
hashed, _ := util.EncodePassword("password", "salt") |
||||||
|
userService := &usertest.FakeUserService{ |
||||||
|
ExpectedUser: &user.User{ID: 1, Email: "user@domain.com", Login: "user", Password: user.Password(hashed), Salt: "salt"}, |
||||||
|
} |
||||||
|
las := &loginattempttest.FakeLoginAttemptService{ExpectedValid: !tt.blockLogin} |
||||||
|
tus := &tempusertest.FakeTempUserService{} |
||||||
|
ns := notifications.MockNotificationService() |
||||||
|
cache := remotecache.NewFakeCacheStorage() |
||||||
|
|
||||||
|
if !tt.findUser { |
||||||
|
userService.ExpectedUser = nil |
||||||
|
userService.ExpectedError = user.ErrUserNotFound |
||||||
|
} |
||||||
|
|
||||||
|
c := ProvidePasswordless(setting.NewCfg(), las, userService, tus, ns, cache) |
||||||
|
code, err := c.startPasswordless(context.Background(), tt.email) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("failed to start passwordless: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
form := &PasswordlessForm{ |
||||||
|
Code: code, |
||||||
|
ConfirmationCode: ns.Email.Data["ConfirmationCode"].(string), |
||||||
|
Name: "user", |
||||||
|
Username: "username", |
||||||
|
} |
||||||
|
identity, err := c.authenticatePasswordless(context.Background(), &authn.Request{OrgID: 1}, *form) |
||||||
|
assert.ErrorIs(t, err, tt.expectedErr) |
||||||
|
assert.EqualValues(t, tt.expectedIdentity, identity) |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
@ -0,0 +1,17 @@ |
|||||||
|
package setting |
||||||
|
|
||||||
|
import "time" |
||||||
|
|
||||||
|
type AuthPasswordlessMagicLinkSettings struct { |
||||||
|
// Passwordless Auth via Magic Link
|
||||||
|
Enabled bool |
||||||
|
CodeExpiration time.Duration |
||||||
|
} |
||||||
|
|
||||||
|
func (cfg *Cfg) readPasswordlessMagicLinkSettings() { |
||||||
|
authPasswordless := cfg.SectionWithEnvOverrides("auth.passwordless") |
||||||
|
PasswordlessMagicLinkSettings := AuthPasswordlessMagicLinkSettings{} |
||||||
|
PasswordlessMagicLinkSettings.Enabled = authPasswordless.Key("enabled").MustBool(false) |
||||||
|
PasswordlessMagicLinkSettings.CodeExpiration = authPasswordless.Key("code_expiration").MustDuration(time.Minute * 20) |
||||||
|
cfg.PasswordlessMagicLinkAuth = PasswordlessMagicLinkSettings |
||||||
|
} |
||||||
@ -0,0 +1,135 @@ |
|||||||
|
import { css } from '@emotion/css'; |
||||||
|
import { useId, useEffect, useState } from 'react'; |
||||||
|
import { useForm } from 'react-hook-form'; |
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data'; |
||||||
|
import { selectors } from '@grafana/e2e-selectors'; |
||||||
|
import { locationService } from '@grafana/runtime'; |
||||||
|
import { Button, Input, Field, useStyles2 } from '@grafana/ui'; |
||||||
|
import { Branding } from 'app/core/components/Branding/Branding'; |
||||||
|
import { t } from 'app/core/internationalization'; |
||||||
|
|
||||||
|
import { PasswordlessConfirmationFormModel } from './LoginCtrl'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
onSubmit: (data: PasswordlessConfirmationFormModel) => void; |
||||||
|
isLoggingIn: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
export const PasswordlessConfirmation = ({ onSubmit, isLoggingIn }: Props) => { |
||||||
|
const styles = useStyles2(getStyles); |
||||||
|
const confirmationCodeId = useId(); |
||||||
|
const codeId = useId(); |
||||||
|
const usernameId = useId(); |
||||||
|
const nameId = useId(); |
||||||
|
const [signup, setSignup] = useState(false); |
||||||
|
|
||||||
|
const { |
||||||
|
handleSubmit, |
||||||
|
register, |
||||||
|
setValue, |
||||||
|
formState: { errors }, |
||||||
|
} = useForm<PasswordlessConfirmationFormModel>({ mode: 'onChange' }); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
Branding.LoginTitle = "We've sent you an email!"; |
||||||
|
Branding.GetLoginSubTitle = () => |
||||||
|
"Check your inbox and click the confirmation link or use the confirmation code we've sent."; |
||||||
|
|
||||||
|
const queryValues = locationService.getSearch(); |
||||||
|
|
||||||
|
setValue('code', queryValues.get('code') || ''); |
||||||
|
if (queryValues.get('confirmationCode')) { |
||||||
|
setValue('confirmationCode', queryValues.get('confirmationCode') || ''); |
||||||
|
if (!queryValues.get('signup')) { |
||||||
|
handleSubmit(onSubmit)(); |
||||||
|
} |
||||||
|
} |
||||||
|
if (queryValues.get('signup')) { |
||||||
|
setSignup(true); |
||||||
|
} |
||||||
|
if (queryValues.get('username')) { |
||||||
|
setValue('username', queryValues.get('username') || ''); |
||||||
|
} |
||||||
|
if (queryValues.get('name')) { |
||||||
|
setValue('name', queryValues.get('name') || ''); |
||||||
|
} |
||||||
|
}, [setValue, handleSubmit, onSubmit, setSignup]); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={styles.wrapper}> |
||||||
|
<form onSubmit={handleSubmit(onSubmit)}> |
||||||
|
<Field hidden={true}> |
||||||
|
<Input {...register('code')} id={codeId} hidden={true} /> |
||||||
|
</Field> |
||||||
|
<Field |
||||||
|
label={t('login.form.confirmation-code-label', 'Confirmation code')} |
||||||
|
invalid={!!errors.code} |
||||||
|
error={errors.code?.message} |
||||||
|
> |
||||||
|
<Input |
||||||
|
{...register('confirmationCode', { |
||||||
|
required: t('login.form.confirmation-code', 'Confirmation code is required'), |
||||||
|
})} |
||||||
|
id={confirmationCodeId} |
||||||
|
autoFocus |
||||||
|
autoCapitalize="none" |
||||||
|
placeholder={t('login.form.confirmation-code-placeholder', 'confirmation code')} |
||||||
|
data-testid={selectors.pages.PasswordlessLogin.email} |
||||||
|
/> |
||||||
|
</Field> |
||||||
|
{signup && ( |
||||||
|
<> |
||||||
|
<Field label={'Username'} invalid={!!errors.code} error={errors.code?.message} hidden={true}> |
||||||
|
<Input |
||||||
|
{...register('username')} |
||||||
|
id={usernameId} |
||||||
|
autoFocus |
||||||
|
autoCapitalize="none" |
||||||
|
placeholder={'username'} |
||||||
|
data-testid={selectors.pages.PasswordlessLogin.email} |
||||||
|
hidden={true} |
||||||
|
/> |
||||||
|
</Field> |
||||||
|
<Field label={t('login.form.name-label', 'Name')} invalid={!!errors.code} error={errors.code?.message}> |
||||||
|
<Input |
||||||
|
{...register('name')} |
||||||
|
id={nameId} |
||||||
|
autoFocus |
||||||
|
autoCapitalize="none" |
||||||
|
placeholder={t('login.form.name-placeholder', 'name')} |
||||||
|
data-testid={selectors.pages.PasswordlessLogin.email} |
||||||
|
/> |
||||||
|
</Field> |
||||||
|
</> |
||||||
|
)} |
||||||
|
<Button |
||||||
|
type="submit" |
||||||
|
data-testid={selectors.pages.Login.submit} |
||||||
|
className={styles.submitButton} |
||||||
|
disabled={isLoggingIn} |
||||||
|
> |
||||||
|
{isLoggingIn ? t('login.form.submit-loading-label', 'Logging in...') : t('login.form.submit-label', 'Log in')} |
||||||
|
</Button> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
export const getStyles = (theme: GrafanaTheme2) => { |
||||||
|
return { |
||||||
|
wrapper: css({ |
||||||
|
width: '100%', |
||||||
|
paddingBottom: theme.spacing(2), |
||||||
|
}), |
||||||
|
|
||||||
|
submitButton: css({ |
||||||
|
justifyContent: 'center', |
||||||
|
width: '100%', |
||||||
|
}), |
||||||
|
|
||||||
|
skipButton: css({ |
||||||
|
alignSelf: 'flex-start', |
||||||
|
}), |
||||||
|
}; |
||||||
|
}; |
||||||
@ -0,0 +1,70 @@ |
|||||||
|
import { css } from '@emotion/css'; |
||||||
|
import { useId } from 'react'; |
||||||
|
import { useForm } from 'react-hook-form'; |
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data'; |
||||||
|
import { selectors } from '@grafana/e2e-selectors'; |
||||||
|
import { Button, Input, Field, useStyles2 } from '@grafana/ui'; |
||||||
|
import { t } from 'app/core/internationalization'; |
||||||
|
|
||||||
|
import { PasswordlessFormModel } from './LoginCtrl'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
onSubmit: (data: PasswordlessFormModel) => void; |
||||||
|
isLoggingIn: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
export const PasswordlessLoginForm = ({ onSubmit, isLoggingIn }: Props) => { |
||||||
|
const styles = useStyles2(getStyles); |
||||||
|
const emailId = useId(); |
||||||
|
const { |
||||||
|
handleSubmit, |
||||||
|
register, |
||||||
|
formState: { errors }, |
||||||
|
} = useForm<PasswordlessFormModel>({ mode: 'onChange' }); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={styles.wrapper}> |
||||||
|
<form onSubmit={handleSubmit(onSubmit)}> |
||||||
|
<Field label={t('login.form.email-label', 'Email')} invalid={!!errors.email} error={errors.email?.message}> |
||||||
|
<Input |
||||||
|
{...register('email', { required: t('login.form.email-required', 'Email is required') })} |
||||||
|
id={emailId} |
||||||
|
autoFocus |
||||||
|
autoCapitalize="none" |
||||||
|
placeholder={t('login.form.email-placeholder', 'email')} |
||||||
|
data-testid={selectors.pages.PasswordlessLogin.email} |
||||||
|
/> |
||||||
|
</Field> |
||||||
|
<Button |
||||||
|
type="submit" |
||||||
|
data-testid={selectors.pages.Login.submit} |
||||||
|
className={styles.submitButton} |
||||||
|
disabled={isLoggingIn} |
||||||
|
> |
||||||
|
{isLoggingIn |
||||||
|
? t('login.form.verify-email-loading-label', 'Sending email...') |
||||||
|
: t('login.form.verify-email-label', 'Send a verification email')} |
||||||
|
</Button> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
export const getStyles = (theme: GrafanaTheme2) => { |
||||||
|
return { |
||||||
|
wrapper: css({ |
||||||
|
width: '100%', |
||||||
|
paddingBottom: theme.spacing(2), |
||||||
|
}), |
||||||
|
|
||||||
|
submitButton: css({ |
||||||
|
justifyContent: 'center', |
||||||
|
width: '100%', |
||||||
|
}), |
||||||
|
|
||||||
|
skipButton: css({ |
||||||
|
alignSelf: 'flex-start', |
||||||
|
}), |
||||||
|
}; |
||||||
|
}; |
||||||
@ -0,0 +1,273 @@ |
|||||||
|
<!doctype html> |
||||||
|
<html lang="und" dir="auto" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"> |
||||||
|
|
||||||
|
<head> |
||||||
|
<title>{{ Subject .Subject .TemplateData "Verify your email" }}</title> |
||||||
|
<!--[if !mso]><!--> |
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge"> |
||||||
|
<!--<![endif]--> |
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"> |
||||||
|
<style type="text/css"> |
||||||
|
#outlook a { |
||||||
|
padding: 0; |
||||||
|
} |
||||||
|
|
||||||
|
body { |
||||||
|
margin: 0; |
||||||
|
padding: 0; |
||||||
|
-webkit-text-size-adjust: 100%; |
||||||
|
-ms-text-size-adjust: 100%; |
||||||
|
} |
||||||
|
|
||||||
|
table, |
||||||
|
td { |
||||||
|
border-collapse: collapse; |
||||||
|
mso-table-lspace: 0pt; |
||||||
|
mso-table-rspace: 0pt; |
||||||
|
} |
||||||
|
|
||||||
|
img { |
||||||
|
border: 0; |
||||||
|
height: auto; |
||||||
|
line-height: 100%; |
||||||
|
outline: none; |
||||||
|
text-decoration: none; |
||||||
|
-ms-interpolation-mode: bicubic; |
||||||
|
} |
||||||
|
|
||||||
|
p { |
||||||
|
display: block; |
||||||
|
margin: 13px 0; |
||||||
|
} |
||||||
|
|
||||||
|
</style> |
||||||
|
<!--[if mso]> |
||||||
|
<noscript> |
||||||
|
<xml> |
||||||
|
<o:OfficeDocumentSettings> |
||||||
|
<o:AllowPNG/> |
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch> |
||||||
|
</o:OfficeDocumentSettings> |
||||||
|
</xml> |
||||||
|
</noscript> |
||||||
|
<![endif]--> |
||||||
|
<!--[if lte mso 11]> |
||||||
|
<style type="text/css"> |
||||||
|
.mj-outlook-group-fix { width:100% !important; } |
||||||
|
</style> |
||||||
|
<![endif]--> |
||||||
|
<!--[if !mso]><!--> |
||||||
|
<link href="https://fonts.googleapis.com/css?family=Inter" rel="stylesheet" type="text/css"> |
||||||
|
<style type="text/css"> |
||||||
|
@import url(https://fonts.googleapis.com/css?family=Inter); |
||||||
|
|
||||||
|
</style> |
||||||
|
<!--<![endif]--> |
||||||
|
<style type="text/css"> |
||||||
|
@media only screen and (min-width:480px) { |
||||||
|
.mj-column-per-100 { |
||||||
|
width: 100% !important; |
||||||
|
max-width: 100%; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
</style> |
||||||
|
<style media="screen and (min-width:480px)"> |
||||||
|
.moz-text-html .mj-column-per-100 { |
||||||
|
width: 100% !important; |
||||||
|
max-width: 100%; |
||||||
|
} |
||||||
|
|
||||||
|
</style> |
||||||
|
<style type="text/css"> |
||||||
|
@media only screen and (max-width:479px) { |
||||||
|
table.mj-full-width-mobile { |
||||||
|
width: 100% !important; |
||||||
|
} |
||||||
|
|
||||||
|
td.mj-full-width-mobile { |
||||||
|
width: auto !important; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
</style> |
||||||
|
</head> |
||||||
|
|
||||||
|
<body style="word-spacing:normal;"> |
||||||
|
<div class="canvas" style="background-color: #fff;" lang="und" dir="auto"> |
||||||
|
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> |
||||||
|
<div style="margin:0px auto;max-width:600px;"> |
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"> |
||||||
|
<tbody> |
||||||
|
<tr> |
||||||
|
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;"> |
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> |
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"> |
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="background-color:transparent;vertical-align:top;" width="100%"> |
||||||
|
<tbody> |
||||||
|
<tr> |
||||||
|
<td align="left" style="font-size:0px;padding:0;word-break:break-word;"> |
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;"> |
||||||
|
<tbody> |
||||||
|
<tr> |
||||||
|
<td style="width:200px;"> |
||||||
|
<img alt src="https://grafana.com/static/assets/img/logo_new_transparent_light_400x100.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="200" height="auto"> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]--> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="background-outlook" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> |
||||||
|
<div class="background" style="background-color: #FFF; border: 1px solid #e4e5e6; margin: 0px auto; max-width: 600px;"> |
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"> |
||||||
|
<tbody> |
||||||
|
<tr> |
||||||
|
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;"> |
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> |
||||||
|
<div style="margin:0px auto;max-width:600px;"> |
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"> |
||||||
|
<tbody> |
||||||
|
<tr> |
||||||
|
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;"> |
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> |
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"> |
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"> |
||||||
|
<tbody> |
||||||
|
<tr> |
||||||
|
<td align="left" class="txt" style="font-size:0px;padding:10px 25px;word-break:break-word;"> |
||||||
|
<div style="font-family: Inter, Helvetica, Arial; font-size: 13px; line-height: 150%; text-align: left; color: #000000;"> |
||||||
|
<h2>Please verify your email</h2> |
||||||
|
</div> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
<tr> |
||||||
|
<td align="left" class="txt" style="font-size:0px;padding:10px 25px;word-break:break-word;"> |
||||||
|
<div style="font-family: Inter, Helvetica, Arial; font-size: 13px; line-height: 150%; text-align: left; color: #000000;">Copy and paste the confirmation code in the login form to verify your email address. This confirmation code will expire in {{ .Expire }} minutes.</div> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]--> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> |
||||||
|
<div style="margin:0px auto;max-width:600px;"> |
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"> |
||||||
|
<tbody> |
||||||
|
<tr> |
||||||
|
<td style="direction:ltr;font-size:0px;padding:10px 25px;text-align:center;"> |
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="well-outlook" style="vertical-align:top;width:550px;" ><![endif]--> |
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix well" style="background-color: #F4F5F5; border: 1px solid #e4e5e6; font-size: 0px; text-align: left; direction: ltr; display: inline-block; vertical-align: top; width: 100%;"> |
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"> |
||||||
|
<tbody> |
||||||
|
<tr> |
||||||
|
<td align="center" class="txt" style="font-size:0px;padding:10px 25px;word-break:break-word;"> |
||||||
|
<div style="font-family: Inter, Helvetica, Arial; font-size: 22px; font-weight: bold; line-height: 150%; text-align: center; color: #000000;">{{ .ConfirmationCode }}</div> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]--> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> |
||||||
|
<div style="margin:0px auto;max-width:600px;"> |
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"> |
||||||
|
<tbody> |
||||||
|
<tr> |
||||||
|
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;"> |
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> |
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"> |
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"> |
||||||
|
<tbody> |
||||||
|
<tr> |
||||||
|
<td align="left" class="txt" style="font-size:0px;padding:10px 25px;word-break:break-word;"> |
||||||
|
<div style="font-family: Inter, Helvetica, Arial; font-size: 13px; line-height: 150%; text-align: left; color: #000000;">Alternatively, you can use the button below to verify your email address.</div> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
<tr> |
||||||
|
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;"> |
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;"> |
||||||
|
<tbody> |
||||||
|
<tr> |
||||||
|
<td align="center" bgcolor="#3D71D9" role="presentation" style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#3D71D9;" valign="middle"> |
||||||
|
<a href="{{ .AppUrl }}login/?code={{ .Code }}&confirmationCode={{ .ConfirmationCode }}" rel="noopener" style="display: inline-block; background: #3D71D9; color: #ffffff; font-family: Inter, Helvetica, Arial; font-size: 13px; font-weight: normal; line-height: 120%; margin: 0; text-decoration: none; text-transform: none; padding: 10px 25px; mso-padding-alt: 0px; border-radius: 3px;" target="_blank"> Verify your email </a> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
<tr> |
||||||
|
<td align="left" class="txt" style="font-size:0px;padding:10px 25px;word-break:break-word;"> |
||||||
|
<div style="font-family: Inter, Helvetica, Arial; font-size: 13px; line-height: 150%; text-align: left; color: #000000;">You can also copy and paste this link into your browser directly:</div> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
<tr> |
||||||
|
<td align="left" class="txt" style="font-size:0px;padding:10px 25px;word-break:break-word;"> |
||||||
|
<div style="font-family: Inter, Helvetica, Arial; font-size: 13px; line-height: 150%; text-align: left; color: #000000;"><a rel="noopener" href="{{ .AppUrl }}login?code={{ .Code }}&confirmationCode={{ .ConfirmationCode }}" style="color: #6E9FFF;">{{ .AppUrl }}login?code={{ .Code }}&confirmationCode={{ .ConfirmationCode }}</a></div> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]--> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]--> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> |
||||||
|
<div style="margin:0px auto;max-width:600px;"> |
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"> |
||||||
|
<tbody> |
||||||
|
<tr> |
||||||
|
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;"> |
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> |
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"> |
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="background-color:transparent;vertical-align:top;" width="100%"> |
||||||
|
<tbody> |
||||||
|
<tr> |
||||||
|
<td align="center" class="txt" style="font-size:0px;padding:10px 25px;word-break:break-word;"> |
||||||
|
<div style="font-family: Inter, Helvetica, Arial; font-size: 13px; line-height: 150%; text-align: center; color: #000000;">© {{ now | date "2006" }} Grafana Labs. Sent by <a href="{{ .AppUrl }}" style="color: #6E9FFF;">Grafana v{{ .BuildVersion }}</a>.</div> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]--> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]--> |
||||||
|
</div> |
||||||
|
</body> |
||||||
|
|
||||||
|
</html> |
||||||
@ -0,0 +1,12 @@ |
|||||||
|
[[HiddenSubject .Subject "Verify your email"]] |
||||||
|
|
||||||
|
Hi, |
||||||
|
|
||||||
|
Copy and paste the confirmation code in the login form to verify your email address. |
||||||
|
|
||||||
|
Copy and paste the email verification code: |
||||||
|
[[.ConfirmationCode]] |
||||||
|
in the in the login form to verify your email address. This confirmation code will expire in {{ .Expire }} minutes. |
||||||
|
Alternatively, you can use the button below to verify your email address. |
||||||
|
|
||||||
|
[[.AppUrl]]login/?code=[[.Code]]&confirmationCode=[[.ConfirmationCode]] |
||||||
@ -0,0 +1,273 @@ |
|||||||
|
<!doctype html> |
||||||
|
<html lang="und" dir="auto" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"> |
||||||
|
|
||||||
|
<head> |
||||||
|
<title>{{ Subject .Subject .TemplateData "Welcome to Grafana, please complete your sign up!" }}</title> |
||||||
|
<!--[if !mso]><!--> |
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge"> |
||||||
|
<!--<![endif]--> |
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"> |
||||||
|
<style type="text/css"> |
||||||
|
#outlook a { |
||||||
|
padding: 0; |
||||||
|
} |
||||||
|
|
||||||
|
body { |
||||||
|
margin: 0; |
||||||
|
padding: 0; |
||||||
|
-webkit-text-size-adjust: 100%; |
||||||
|
-ms-text-size-adjust: 100%; |
||||||
|
} |
||||||
|
|
||||||
|
table, |
||||||
|
td { |
||||||
|
border-collapse: collapse; |
||||||
|
mso-table-lspace: 0pt; |
||||||
|
mso-table-rspace: 0pt; |
||||||
|
} |
||||||
|
|
||||||
|
img { |
||||||
|
border: 0; |
||||||
|
height: auto; |
||||||
|
line-height: 100%; |
||||||
|
outline: none; |
||||||
|
text-decoration: none; |
||||||
|
-ms-interpolation-mode: bicubic; |
||||||
|
} |
||||||
|
|
||||||
|
p { |
||||||
|
display: block; |
||||||
|
margin: 13px 0; |
||||||
|
} |
||||||
|
|
||||||
|
</style> |
||||||
|
<!--[if mso]> |
||||||
|
<noscript> |
||||||
|
<xml> |
||||||
|
<o:OfficeDocumentSettings> |
||||||
|
<o:AllowPNG/> |
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch> |
||||||
|
</o:OfficeDocumentSettings> |
||||||
|
</xml> |
||||||
|
</noscript> |
||||||
|
<![endif]--> |
||||||
|
<!--[if lte mso 11]> |
||||||
|
<style type="text/css"> |
||||||
|
.mj-outlook-group-fix { width:100% !important; } |
||||||
|
</style> |
||||||
|
<![endif]--> |
||||||
|
<!--[if !mso]><!--> |
||||||
|
<link href="https://fonts.googleapis.com/css?family=Inter" rel="stylesheet" type="text/css"> |
||||||
|
<style type="text/css"> |
||||||
|
@import url(https://fonts.googleapis.com/css?family=Inter); |
||||||
|
|
||||||
|
</style> |
||||||
|
<!--<![endif]--> |
||||||
|
<style type="text/css"> |
||||||
|
@media only screen and (min-width:480px) { |
||||||
|
.mj-column-per-100 { |
||||||
|
width: 100% !important; |
||||||
|
max-width: 100%; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
</style> |
||||||
|
<style media="screen and (min-width:480px)"> |
||||||
|
.moz-text-html .mj-column-per-100 { |
||||||
|
width: 100% !important; |
||||||
|
max-width: 100%; |
||||||
|
} |
||||||
|
|
||||||
|
</style> |
||||||
|
<style type="text/css"> |
||||||
|
@media only screen and (max-width:479px) { |
||||||
|
table.mj-full-width-mobile { |
||||||
|
width: 100% !important; |
||||||
|
} |
||||||
|
|
||||||
|
td.mj-full-width-mobile { |
||||||
|
width: auto !important; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
</style> |
||||||
|
</head> |
||||||
|
|
||||||
|
<body style="word-spacing:normal;"> |
||||||
|
<div class="canvas" style="background-color: #fff;" lang="und" dir="auto"> |
||||||
|
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> |
||||||
|
<div style="margin:0px auto;max-width:600px;"> |
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"> |
||||||
|
<tbody> |
||||||
|
<tr> |
||||||
|
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;"> |
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> |
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"> |
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="background-color:transparent;vertical-align:top;" width="100%"> |
||||||
|
<tbody> |
||||||
|
<tr> |
||||||
|
<td align="left" style="font-size:0px;padding:0;word-break:break-word;"> |
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;"> |
||||||
|
<tbody> |
||||||
|
<tr> |
||||||
|
<td style="width:200px;"> |
||||||
|
<img alt src="https://grafana.com/static/assets/img/logo_new_transparent_light_400x100.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="200" height="auto"> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]--> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="background-outlook" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> |
||||||
|
<div class="background" style="background-color: #FFF; border: 1px solid #e4e5e6; margin: 0px auto; max-width: 600px;"> |
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"> |
||||||
|
<tbody> |
||||||
|
<tr> |
||||||
|
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;"> |
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> |
||||||
|
<div style="margin:0px auto;max-width:600px;"> |
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"> |
||||||
|
<tbody> |
||||||
|
<tr> |
||||||
|
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;"> |
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> |
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"> |
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"> |
||||||
|
<tbody> |
||||||
|
<tr> |
||||||
|
<td align="left" class="txt" style="font-size:0px;padding:10px 25px;word-break:break-word;"> |
||||||
|
<div style="font-family: Inter, Helvetica, Arial; font-size: 13px; line-height: 150%; text-align: left; color: #000000;"> |
||||||
|
<h2>Please complete your signup</h2> |
||||||
|
</div> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
<tr> |
||||||
|
<td align="left" class="txt" style="font-size:0px;padding:10px 25px;word-break:break-word;"> |
||||||
|
<div style="font-family: Inter, Helvetica, Arial; font-size: 13px; line-height: 150%; text-align: left; color: #000000;">Copy and paste the confirmation code in the sign up form to verify your email address. This confirmation code will expire in {{ .Expire }} minutes.</div> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]--> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> |
||||||
|
<div style="margin:0px auto;max-width:600px;"> |
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"> |
||||||
|
<tbody> |
||||||
|
<tr> |
||||||
|
<td style="direction:ltr;font-size:0px;padding:10px 25px;text-align:center;"> |
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="well-outlook" style="vertical-align:top;width:550px;" ><![endif]--> |
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix well" style="background-color: #F4F5F5; border: 1px solid #e4e5e6; font-size: 0px; text-align: left; direction: ltr; display: inline-block; vertical-align: top; width: 100%;"> |
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"> |
||||||
|
<tbody> |
||||||
|
<tr> |
||||||
|
<td align="center" class="txt" style="font-size:0px;padding:10px 25px;word-break:break-word;"> |
||||||
|
<div style="font-family: Inter, Helvetica, Arial; font-size: 22px; font-weight: bold; line-height: 150%; text-align: center; color: #000000;">{{ .ConfirmationCode }}</div> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]--> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> |
||||||
|
<div style="margin:0px auto;max-width:600px;"> |
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"> |
||||||
|
<tbody> |
||||||
|
<tr> |
||||||
|
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;"> |
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> |
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"> |
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"> |
||||||
|
<tbody> |
||||||
|
<tr> |
||||||
|
<td align="left" class="txt" style="font-size:0px;padding:10px 25px;word-break:break-word;"> |
||||||
|
<div style="font-family: Inter, Helvetica, Arial; font-size: 13px; line-height: 150%; text-align: left; color: #000000;">Alternatively, you can use the button below to complete your sign up.</div> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
<tr> |
||||||
|
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;"> |
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;"> |
||||||
|
<tbody> |
||||||
|
<tr> |
||||||
|
<td align="center" bgcolor="#3D71D9" role="presentation" style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#3D71D9;" valign="middle"> |
||||||
|
<a href="{{ .AppUrl }}login/?code={{ .Code }}&confirmationCode={{ .ConfirmationCode }}&signup=true" rel="noopener" style="display: inline-block; background: #3D71D9; color: #ffffff; font-family: Inter, Helvetica, Arial; font-size: 13px; font-weight: normal; line-height: 120%; margin: 0; text-decoration: none; text-transform: none; padding: 10px 25px; mso-padding-alt: 0px; border-radius: 3px;" target="_blank"> Complete Sign Up </a> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
<tr> |
||||||
|
<td align="left" class="txt" style="font-size:0px;padding:10px 25px;word-break:break-word;"> |
||||||
|
<div style="font-family: Inter, Helvetica, Arial; font-size: 13px; line-height: 150%; text-align: left; color: #000000;">You can also copy and paste this link into your browser directly:</div> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
<tr> |
||||||
|
<td align="left" class="txt" style="font-size:0px;padding:10px 25px;word-break:break-word;"> |
||||||
|
<div style="font-family: Inter, Helvetica, Arial; font-size: 13px; line-height: 150%; text-align: left; color: #000000;"><a rel="noopener" href="{{ .AppUrl }}login?code={{ .Code }}&confirmationCode={{ .ConfirmationCode }}&signup=true" style="color: #6E9FFF;">{{ .AppUrl }}login?code={{ .Code }}&confirmationCode={{ .ConfirmationCode }}&signup=true</a></div> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]--> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]--> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> |
||||||
|
<div style="margin:0px auto;max-width:600px;"> |
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"> |
||||||
|
<tbody> |
||||||
|
<tr> |
||||||
|
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;"> |
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> |
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"> |
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="background-color:transparent;vertical-align:top;" width="100%"> |
||||||
|
<tbody> |
||||||
|
<tr> |
||||||
|
<td align="center" class="txt" style="font-size:0px;padding:10px 25px;word-break:break-word;"> |
||||||
|
<div style="font-family: Inter, Helvetica, Arial; font-size: 13px; line-height: 150%; text-align: center; color: #000000;">© {{ now | date "2006" }} Grafana Labs. Sent by <a href="{{ .AppUrl }}" style="color: #6E9FFF;">Grafana v{{ .BuildVersion }}</a>.</div> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]--> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]--> |
||||||
|
</div> |
||||||
|
</body> |
||||||
|
|
||||||
|
</html> |
||||||
@ -0,0 +1,12 @@ |
|||||||
|
[[HiddenSubject .Subject "Welcome to Grafana, please complete your signup!"]] |
||||||
|
|
||||||
|
Hi, |
||||||
|
|
||||||
|
Copy and paste the confirmation code in the login form to verify your email address. |
||||||
|
|
||||||
|
Copy and paste the email verification code: |
||||||
|
[[.ConfirmationCode]] |
||||||
|
in the in the login form to verify your email address. This confirmation code will expire in {{ .Expire }} minutes. |
||||||
|
Alternatively, you can use the button below to verify your email address. |
||||||
|
|
||||||
|
[[.AppUrl]]login/?code=[[.Code]]&confirmationCode=[[.ConfirmationCode]] |
||||||
Loading…
Reference in new issue