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 &amp; 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
colin-stuart 7 months ago committed by GitHub
parent c865958292
commit 6abe99efd6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      conf/defaults.ini
  2. 51
      emails/templates/passwordless_verify_existing_user.mjml
  3. 10
      emails/templates/passwordless_verify_existing_user.txt
  4. 53
      emails/templates/passwordless_verify_new_user.mjml
  5. 10
      emails/templates/passwordless_verify_new_user.txt
  6. 1
      packages/grafana-data/src/types/config.ts
  7. 1
      packages/grafana-data/src/types/featureToggles.gen.ts
  8. 13
      packages/grafana-e2e-selectors/src/selectors/pages.ts
  9. 6
      pkg/api/api.go
  10. 2
      pkg/api/dtos/frontend_settings.go
  11. 1
      pkg/api/frontendsettings.go
  12. 20
      pkg/api/login.go
  13. 21
      pkg/services/authn/authn.go
  14. 9
      pkg/services/authn/authnimpl/registration.go
  15. 335
      pkg/services/authn/clients/passwordless.go
  16. 160
      pkg/services/authn/clients/passwordless_test.go
  17. 9
      pkg/services/featuremgmt/registry.go
  18. 1
      pkg/services/featuremgmt/toggles_gen.csv
  19. 4
      pkg/services/featuremgmt/toggles_gen.go
  20. 14
      pkg/services/featuremgmt/toggles_gen.json
  21. 17
      pkg/services/login/authinfo.go
  22. 8
      pkg/services/temp_user/tempusertest/fake.go
  23. 3
      pkg/setting/setting.go
  24. 17
      pkg/setting/setting_passwordless_magic_link.go
  25. 3
      public/app/core/components/ForgottenPassword/ChangePasswordPage.test.tsx
  26. 71
      public/app/core/components/Login/LoginCtrl.tsx
  27. 23
      public/app/core/components/Login/LoginPage.tsx
  28. 135
      public/app/core/components/Login/PasswordlessConfirmationForm.tsx
  29. 70
      public/app/core/components/Login/PasswordlessLoginForm.tsx
  30. 4
      public/app/core/components/Login/types.ts
  31. 273
      public/emails/passwordless_verify_existing_user.html
  32. 12
      public/emails/passwordless_verify_existing_user.txt
  33. 273
      public/emails/passwordless_verify_new_user.html
  34. 12
      public/emails/passwordless_verify_new_user.txt
  35. 12
      public/locales/en-US/grafana.json
  36. 12
      public/locales/pseudo-LOCALE/grafana.json

@ -626,6 +626,11 @@ id_response_header_namespaces = user api-key service-account
# This feature currently **only supports single-organization deployments**
managed_service_accounts_enabled = false
#################################### Passwordless Auth ###########################
[auth.passwordless]
enabled = false
code_expiration = 20m
#################################### SSO Settings ###########################
[sso_settings]
# interval for reloading the SSO Settings from the database

@ -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 }}&amp;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 }}&amp;confirmationCode={{ .ConfirmationCode }}"
>{{ .AppUrl }}login?code={{ .Code }}&amp;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 }}&amp;confirmationCode={{ .ConfirmationCode }}&amp;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 }}&amp;confirmationCode={{ .ConfirmationCode }}&amp;signup=true"
>{{ .AppUrl }}login?code={{ .Code }}&amp;confirmationCode={{ .ConfirmationCode }}&amp;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]]

@ -278,5 +278,6 @@ export interface AuthSettings {
GenericOAuthSkipOrgRoleSync?: boolean;
disableLogin?: boolean;
passwordlessEnabled?: boolean;
basicAuthStrongPasswordPolicy?: boolean;
}

@ -232,6 +232,7 @@ export interface FeatureToggles {
preinstallAutoUpdate?: boolean;
dashboardSchemaV2?: boolean;
playlistsWatcher?: boolean;
passwordlessMagicLinkAuthentication?: boolean;
exploreMetricsRelatedLogs?: boolean;
enableExtensionsAdminPage?: boolean;
zipkinBackendMigration?: boolean;

@ -39,6 +39,19 @@ export const versionedPages = {
'10.2.3': 'data-testid Skip change password button',
},
},
PasswordlessLogin: {
url: {
[MIN_GRAFANA_VERSION]: '/login/passwordless/authenticate',
},
email: {
'10.2.3': 'data-testid Email input field',
[MIN_GRAFANA_VERSION]: 'Email input field',
},
submit: {
'10.2.3': 'data-testid PasswordlessLogin button',
[MIN_GRAFANA_VERSION]: 'PasswordlessLogin button',
},
},
Home: {
url: {
[MIN_GRAFANA_VERSION]: '/',

@ -78,6 +78,7 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/logout", hs.Logout)
r.Post("/login", requestmeta.SetOwner(requestmeta.TeamAuth), quota(string(auth.QuotaTargetSrv)), routing.Wrap(hs.LoginPost))
r.Get("/login/:name", quota(string(auth.QuotaTargetSrv)), hs.OAuthLogin)
r.Get("/login", hs.LoginView)
r.Get("/invite/:code", hs.Index)
@ -207,6 +208,11 @@ func (hs *HTTPServer) registerRoutes() {
r.Post("/api/user/email/start-verify", reqSignedInNoAnonymous, routing.Wrap(hs.StartEmailVerificaton))
}
if hs.Cfg.PasswordlessMagicLinkAuth.Enabled && hs.Features.IsEnabledGlobally(featuremgmt.FlagPasswordlessMagicLinkAuthentication) {
r.Post("/api/login/passwordless/start", requestmeta.SetOwner(requestmeta.TeamAuth), quota(string(auth.QuotaTargetSrv)), hs.StartPasswordless)
r.Post("/api/login/passwordless/authenticate", requestmeta.SetOwner(requestmeta.TeamAuth), quota(string(auth.QuotaTargetSrv)), routing.Wrap(hs.LoginPasswordless))
}
// invited
r.Get("/api/user/invite/:code", routing.Wrap(hs.GetInviteInfoByCode))
r.Post("/api/user/invite/complete", routing.Wrap(hs.CompleteInvite))

@ -33,6 +33,7 @@ type FrontendSettingsAuthDTO struct {
DisableLogin bool `json:"disableLogin"`
BasicAuthStrongPasswordPolicy bool `json:"basicAuthStrongPasswordPolicy"`
PasswordlessEnabled bool `json:"passwordlessEnabled"`
}
type FrontendSettingsBuildInfoDTO struct {
@ -253,6 +254,7 @@ type FrontendSettingsDTO struct {
TokenExpirationDayLimit int `json:"tokenExpirationDayLimit"`
SharedWithMeFolderUID string `json:"sharedWithMeFolderUID"`
RootFolderUID string `json:"rootFolderUID"`
PasswordlessEnabled string `json:"passwordlessEnabled"`
GeomapDefaultBaseLayerConfig *map[string]any `json:"geomapDefaultBaseLayerConfig,omitempty"`
GeomapDisableCustomBaseLayer bool `json:"geomapDisableCustomBaseLayer"`

@ -360,6 +360,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
OktaSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.OktaProviderName]),
DisableLogin: hs.Cfg.DisableLogin,
BasicAuthStrongPasswordPolicy: hs.Cfg.BasicAuthStrongPasswordPolicy,
PasswordlessEnabled: hs.Cfg.PasswordlessMagicLinkAuth.Enabled && hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagPasswordlessMagicLinkAuthentication),
}
if hs.pluginsCDNService != nil && hs.pluginsCDNService.IsEnabled() {

@ -241,6 +241,26 @@ func (hs *HTTPServer) LoginPost(c *contextmodel.ReqContext) response.Response {
return authn.HandleLoginResponse(c.Req, c.Resp, hs.Cfg, identity, hs.ValidateRedirectTo, hs.Features)
}
func (hs *HTTPServer) LoginPasswordless(c *contextmodel.ReqContext) response.Response {
identity, err := hs.authnService.Login(c.Req.Context(), authn.ClientPasswordless, &authn.Request{HTTPRequest: c.Req})
if err != nil {
tokenErr := &auth.CreateTokenErr{}
if errors.As(err, &tokenErr) {
return response.Error(tokenErr.StatusCode, tokenErr.ExternalErr, tokenErr.InternalErr)
}
return response.Err(err)
}
return authn.HandleLoginResponse(c.Req, c.Resp, hs.Cfg, identity, hs.ValidateRedirectTo, hs.Features)
}
func (hs *HTTPServer) StartPasswordless(c *contextmodel.ReqContext) {
redirect, err := hs.authnService.RedirectURL(c.Req.Context(), authn.ClientPasswordless, &authn.Request{HTTPRequest: c.Req})
if err != nil {
c.Redirect(hs.redirectURLWithErrorCookie(c, err))
}
c.JSON(http.StatusOK, redirect)
}
func (hs *HTTPServer) loginUserWithUser(user *user.User, c *contextmodel.ReqContext) error {
if user == nil {
return errors.New("could not login user")

@ -19,16 +19,17 @@ import (
)
const (
ClientAPIKey = "auth.client.api-key" // #nosec G101
ClientAnonymous = "auth.client.anonymous"
ClientBasic = "auth.client.basic"
ClientJWT = "auth.client.jwt"
ClientExtendedJWT = "auth.client.extended-jwt"
ClientRender = "auth.client.render"
ClientSession = "auth.client.session"
ClientForm = "auth.client.form"
ClientProxy = "auth.client.proxy"
ClientSAML = "auth.client.saml"
ClientAPIKey = "auth.client.api-key" // #nosec G101
ClientAnonymous = "auth.client.anonymous"
ClientBasic = "auth.client.basic"
ClientJWT = "auth.client.jwt"
ClientExtendedJWT = "auth.client.extended-jwt"
ClientRender = "auth.client.render"
ClientSession = "auth.client.session"
ClientForm = "auth.client.form"
ClientProxy = "auth.client.proxy"
ClientSAML = "auth.client.saml"
ClientPasswordless = "auth.client.passwordless"
)
const (

@ -17,10 +17,12 @@ import (
"github.com/grafana/grafana/pkg/services/ldap/service"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/loginattempt"
"github.com/grafana/grafana/pkg/services/notifications"
"github.com/grafana/grafana/pkg/services/oauthtoken"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/services/rendering"
tempuser "github.com/grafana/grafana/pkg/services/temp_user"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
)
@ -38,7 +40,7 @@ func ProvideRegistration(
features *featuremgmt.FeatureManager, oauthTokenService oauthtoken.OAuthTokenService,
socialService social.Service, cache *remotecache.RemoteCache,
ldapService service.LDAP, settingsProviderService setting.Provider,
tracer tracing.Tracer,
tracer tracing.Tracer, tempUserService tempuser.Service, notificationService notifications.Service,
) Registration {
logger := log.New("authn.registration")
@ -78,6 +80,11 @@ func ProvideRegistration(
}
}
if cfg.PasswordlessMagicLinkAuth.Enabled && features.IsEnabledGlobally(featuremgmt.FlagPasswordlessMagicLinkAuthentication) {
passwordless := clients.ProvidePasswordless(cfg, loginAttempts, userService, tempUserService, notificationService, cache)
authnSvc.RegisterClient(passwordless)
}
if cfg.AuthProxy.Enabled && len(proxyClients) > 0 {
proxy, err := clients.ProvideProxy(cfg, cache, proxyClients...)
if err != nil {

@ -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)
})
}
}

@ -1602,6 +1602,15 @@ var (
Owner: grafanaAppPlatformSquad,
RequiresRestart: true,
},
{
Name: "passwordlessMagicLinkAuthentication",
Description: "Enable passwordless login via magic link authentication",
Stage: FeatureStageExperimental,
Owner: identityAccessTeam,
HideFromDocs: true,
HideFromAdminPage: true,
AllowSelfServe: false,
},
{
Name: "exploreMetricsRelatedLogs",
Description: "Display Related Logs in Explore Metrics",

@ -213,6 +213,7 @@ azureMonitorDisableLogLimit,GA,@grafana/partner-datasources,false,false,false
preinstallAutoUpdate,GA,@grafana/plugins-platform-backend,false,false,false
dashboardSchemaV2,experimental,@grafana/dashboards-squad,false,false,true
playlistsWatcher,experimental,@grafana/grafana-app-platform-squad,false,true,false
passwordlessMagicLinkAuthentication,experimental,@grafana/identity-access-team,false,false,false
exploreMetricsRelatedLogs,experimental,@grafana/observability-metrics,false,false,true
enableExtensionsAdminPage,experimental,@grafana/plugins-platform-backend,false,true,false
zipkinBackendMigration,experimental,@grafana/oss-big-tent,false,false,false

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
213 preinstallAutoUpdate GA @grafana/plugins-platform-backend false false false
214 dashboardSchemaV2 experimental @grafana/dashboards-squad false false true
215 playlistsWatcher experimental @grafana/grafana-app-platform-squad false true false
216 passwordlessMagicLinkAuthentication experimental @grafana/identity-access-team false false false
217 exploreMetricsRelatedLogs experimental @grafana/observability-metrics false false true
218 enableExtensionsAdminPage experimental @grafana/plugins-platform-backend false true false
219 zipkinBackendMigration experimental @grafana/oss-big-tent false false false

@ -863,6 +863,10 @@ const (
// Enables experimental watcher for playlists
FlagPlaylistsWatcher = "playlistsWatcher"
// FlagPasswordlessMagicLinkAuthentication
// Enable passwordless login via magic link authentication
FlagPasswordlessMagicLinkAuthentication = "passwordlessMagicLinkAuthentication"
// FlagExploreMetricsRelatedLogs
// Display Related Logs in Explore Metrics
FlagExploreMetricsRelatedLogs = "exploreMetricsRelatedLogs"

@ -2462,6 +2462,20 @@
"hideFromDocs": true
}
},
{
"metadata": {
"name": "passwordlessMagicLinkAuthentication",
"resourceVersion": "1730232874003",
"creationTimestamp": "2024-10-29T20:14:34Z"
},
"spec": {
"description": "Enable passwordless login via magic link authentication",
"stage": "experimental",
"codeowner": "@grafana/identity-access-team",
"hideFromAdminPage": true,
"hideFromDocs": true
}
},
{
"metadata": {
"name": "pdfTables",

@ -25,14 +25,15 @@ type Store interface {
const (
// modules
PasswordAuthModule = "password"
APIKeyAuthModule = "apikey"
SAMLAuthModule = "auth.saml"
LDAPAuthModule = "ldap"
AuthProxyAuthModule = "authproxy"
JWTModule = "jwt"
ExtendedJWTModule = "extendedjwt"
RenderModule = "render"
PasswordAuthModule = "password"
PasswordlessAuthModule = "passwordless"
APIKeyAuthModule = "apikey"
SAMLAuthModule = "auth.saml"
LDAPAuthModule = "ldap"
AuthProxyAuthModule = "authproxy"
JWTModule = "jwt"
ExtendedJWTModule = "extendedjwt"
RenderModule = "render"
// OAuth provider modules
AzureADAuthModule = "oauth_azuread"
GoogleAuthModule = "oauth_google"

@ -11,6 +11,7 @@ var _ tempuser.Service = (*FakeTempUserService)(nil)
type FakeTempUserService struct {
tempuser.Service
GetTempUserByCodeFN func(ctx context.Context, query *tempuser.GetTempUserByCodeQuery) (*tempuser.TempUserDTO, error)
GetTempUsersQueryFN func(ctx context.Context, query *tempuser.GetTempUsersQuery) ([]*tempuser.TempUserDTO, error)
UpdateTempUserStatusFN func(ctx context.Context, cmd *tempuser.UpdateTempUserStatusCommand) error
CreateTempUserFN func(ctx context.Context, cmd *tempuser.CreateTempUserCommand) (*tempuser.TempUser, error)
ExpirePreviousVerificationsFN func(ctx context.Context, cmd *tempuser.ExpirePreviousVerificationsCommand) error
@ -24,6 +25,13 @@ func (f *FakeTempUserService) GetTempUserByCode(ctx context.Context, query *temp
return nil, nil
}
func (f *FakeTempUserService) GetTempUsersQuery(ctx context.Context, query *tempuser.GetTempUsersQuery) ([]*tempuser.TempUserDTO, error) {
if f.GetTempUsersQueryFN != nil {
return f.GetTempUsersQueryFN(ctx, query)
}
return nil, nil
}
func (f *FakeTempUserService) UpdateTempUserStatus(ctx context.Context, cmd *tempuser.UpdateTempUserStatusCommand) error {
if f.UpdateTempUserStatusFN != nil {
return f.UpdateTempUserStatusFN(ctx, cmd)

@ -271,6 +271,8 @@ type Cfg struct {
JWTAuth AuthJWTSettings
ExtJWTAuth ExtJWTSettings
PasswordlessMagicLinkAuth AuthPasswordlessMagicLinkSettings
// SSO Settings Auth
SSOSettingsReloadInterval time.Duration
SSOSettingsConfigurableProviders map[string]bool
@ -1248,6 +1250,7 @@ func (cfg *Cfg) parseINIFile(iniFile *ini.File) error {
cfg.readAuthExtJWTSettings()
cfg.readAuthProxySettings()
cfg.readSessionConfig()
cfg.readPasswordlessMagicLinkSettings()
if err := cfg.readSmtpSettings(); err != nil {
return err
}

@ -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
}

@ -10,6 +10,9 @@ jest.mock('@grafana/runtime', () => ({
getBackendSrv: () => ({
post: postMock,
}),
locationService: {
getSearch: () => new URLSearchParams(),
},
config: {
...jest.requireActual('@grafana/runtime').config,
loginError: false,

@ -1,21 +1,37 @@
import { PureComponent } from 'react';
import { FetchError, getBackendSrv, isFetchError } from '@grafana/runtime';
import { FetchError, getBackendSrv, isFetchError, locationService } from '@grafana/runtime';
import config from 'app/core/config';
import { t } from 'app/core/internationalization';
import { LoginDTO } from './types';
import { LoginDTO, AuthNRedirectDTO } from './types';
const isOauthEnabled = () => {
return !!config.oauth && Object.keys(config.oauth).length > 0;
};
const showPasswordlessConfirmation = () => {
const queryValues = locationService.getSearch();
return !!queryValues.get('code');
};
export interface FormModel {
user: string;
password: string;
email: string;
}
export interface PasswordlessFormModel {
email: string;
}
export interface PasswordlessConfirmationFormModel {
code: string;
confirmationCode: string;
username?: string;
name?: string;
}
interface Props {
resetCode?: string;
@ -25,6 +41,9 @@ interface Props {
isChangingPassword: boolean;
skipPasswordChange: Function;
login: (data: FormModel) => void;
passwordlessStart: (data: PasswordlessFormModel) => void;
passwordlessConfirm: (data: PasswordlessConfirmationFormModel) => void;
showPasswordlessConfirmation: boolean;
disableLoginForm: boolean;
disableUserSignUp: boolean;
isOauthEnabled: boolean;
@ -111,6 +130,49 @@ export class LoginCtrl extends PureComponent<Props, State> {
});
};
passwordlessStart = (formModel: PasswordlessFormModel) => {
this.setState({
loginErrorMessage: undefined,
isLoggingIn: true,
});
getBackendSrv()
.post<AuthNRedirectDTO>('/api/login/passwordless/start', formModel, { showErrorAlert: false })
.then((result) => {
window.location.assign(result.URL);
return;
})
.catch((err) => {
const fetchErrorMessage = isFetchError(err) ? getErrorMessage(err) : undefined;
this.setState({
isLoggingIn: false,
loginErrorMessage: fetchErrorMessage || t('login.error.unknown', 'Unknown error occurred'),
});
});
};
passwordlessConfirm = (formModel: PasswordlessConfirmationFormModel) => {
this.setState({
loginErrorMessage: undefined,
isLoggingIn: true,
});
getBackendSrv()
.post<LoginDTO>('/api/login/passwordless/authenticate', formModel, { showErrorAlert: false })
.then((result) => {
this.result = result;
this.toGrafana();
return;
})
.catch((err) => {
const fetchErrorMessage = isFetchError(err) ? getErrorMessage(err) : undefined;
this.setState({
isLoggingIn: false,
loginErrorMessage: fetchErrorMessage || t('login.error.unknown', 'Unknown error occurred'),
});
});
};
changeView = (showDefaultPasswordWarning: boolean) => {
this.setState({
isChangingPassword: true,
@ -138,7 +200,7 @@ export class LoginCtrl extends PureComponent<Props, State> {
render() {
const { children } = this.props;
const { isLoggingIn, isChangingPassword, showDefaultPasswordWarning, loginErrorMessage } = this.state;
const { login, toGrafana, changePassword } = this;
const { login, toGrafana, changePassword, passwordlessStart, passwordlessConfirm } = this;
const { loginHint, passwordHint, disableLoginForm, disableUserSignUp } = config;
return (
@ -150,6 +212,9 @@ export class LoginCtrl extends PureComponent<Props, State> {
disableLoginForm,
disableUserSignUp,
login,
passwordlessStart,
passwordlessConfirm,
showPasswordlessConfirmation: showPasswordlessConfirmation(),
isLoggingIn,
changePassword,
skipPasswordChange: toGrafana,

@ -14,6 +14,8 @@ import LoginCtrl from './LoginCtrl';
import { LoginForm } from './LoginForm';
import { LoginLayout, InnerBox } from './LoginLayout';
import { LoginServiceButtons } from './LoginServiceButtons';
import { PasswordlessConfirmation } from './PasswordlessConfirmationForm';
import { PasswordlessLoginForm } from './PasswordlessLoginForm';
import { UserSignup } from './UserSignup';
const LoginPage = () => {
@ -28,6 +30,9 @@ const LoginPage = () => {
disableLoginForm,
disableUserSignUp,
login,
passwordlessStart,
passwordlessConfirm,
showPasswordlessConfirmation,
isLoggingIn,
changePassword,
skipPasswordChange,
@ -36,7 +41,7 @@ const LoginPage = () => {
loginErrorMessage,
}) => (
<LoginLayout isChangingPassword={isChangingPassword}>
{!isChangingPassword && (
{!isChangingPassword && !showPasswordlessConfirmation && (
<InnerBox>
{loginErrorMessage && (
<Alert className={styles.alert} severity="error" title={t('login.error.title', 'Login failed')}>
@ -44,7 +49,7 @@ const LoginPage = () => {
</Alert>
)}
{!disableLoginForm && (
{!disableLoginForm && !config.auth.passwordlessEnabled && (
<LoginForm onSubmit={login} loginHint={loginHint} passwordHint={passwordHint} isLoggingIn={isLoggingIn}>
<Stack justifyContent="flex-end">
{!config.auth.disableLogin && (
@ -59,12 +64,24 @@ const LoginPage = () => {
</Stack>
</LoginForm>
)}
{config.auth.passwordlessEnabled && (
<PasswordlessLoginForm onSubmit={passwordlessStart} isLoggingIn={isLoggingIn}></PasswordlessLoginForm>
)}
<LoginServiceButtons />
{!disableUserSignUp && <UserSignup />}
</InnerBox>
)}
{isChangingPassword && (
{config.auth.passwordlessEnabled && showPasswordlessConfirmation && (
<InnerBox>
<PasswordlessConfirmation
onSubmit={passwordlessConfirm}
isLoggingIn={isLoggingIn}
></PasswordlessConfirmation>
</InnerBox>
)}
{isChangingPassword && !config.auth.passwordlessEnabled && (
<InnerBox>
<ChangePassword
showDefaultPasswordWarning={showDefaultPasswordWarning}

@ -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',
}),
};
};

@ -2,3 +2,7 @@ export interface LoginDTO {
message: string;
redirectUrl: string;
}
export interface AuthNRedirectDTO {
URL: string;
}

@ -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 }}&amp;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 }}&amp;confirmationCode={{ .ConfirmationCode }}" style="color: #6E9FFF;">{{ .AppUrl }}login?code={{ .Code }}&amp;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;">&copy; {{ 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 }}&amp;confirmationCode={{ .ConfirmationCode }}&amp;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 }}&amp;confirmationCode={{ .ConfirmationCode }}&amp;signup=true" style="color: #6E9FFF;">{{ .AppUrl }}login?code={{ .Code }}&amp;confirmationCode={{ .ConfirmationCode }}&amp;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;">&copy; {{ 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]]

@ -1459,6 +1459,14 @@
},
"forgot-password": "Forgot your password?",
"form": {
"confirmation-code": "Confirmation code is required",
"confirmation-code-label": "Confirmation code",
"confirmation-code-placeholder": "confirmation code",
"email-label": "Email",
"email-placeholder": "email",
"email-required": "Email is required",
"name-label": "Name",
"name-placeholder": "name",
"password-label": "Password",
"password-placeholder": "password",
"password-required": "Password is required",
@ -1466,7 +1474,9 @@
"submit-loading-label": "Logging in...",
"username-label": "Email or username",
"username-placeholder": "email or username",
"username-required": "Email or username is required"
"username-required": "Email or username is required",
"verify-email-label": "Send a verification email",
"verify-email-loading-label": "Sending email..."
},
"services": {
"sing-in-with-prefix": "Sign in with {{serviceName}}"

@ -1459,6 +1459,14 @@
},
"forgot-password": "Főřģőŧ yőūř päşşŵőřđ?",
"form": {
"confirmation-code": "Cőʼnƒįřmäŧįőʼn čőđę įş řęqūįřęđ",
"confirmation-code-label": "Cőʼnƒįřmäŧįőʼn čőđę",
"confirmation-code-placeholder": "čőʼnƒįřmäŧįőʼn čőđę",
"email-label": "Ēmäįľ",
"email-placeholder": "ęmäįľ",
"email-required": "Ēmäįľ įş řęqūįřęđ",
"name-label": "Ńämę",
"name-placeholder": "ʼnämę",
"password-label": "Päşşŵőřđ",
"password-placeholder": "päşşŵőřđ",
"password-required": "Päşşŵőřđ įş řęqūįřęđ",
@ -1466,7 +1474,9 @@
"submit-loading-label": "Ŀőģģįʼnģ įʼn...",
"username-label": "Ēmäįľ őř ūşęřʼnämę",
"username-placeholder": "ęmäįľ őř ūşęřʼnämę",
"username-required": "Ēmäįľ őř ūşęřʼnämę įş řęqūįřęđ"
"username-required": "Ēmäįľ őř ūşęřʼnämę įş řęqūįřęđ",
"verify-email-label": "Ŝęʼnđ ä vęřįƒįčäŧįőʼn ęmäįľ",
"verify-email-loading-label": "Ŝęʼnđįʼnģ ęmäįľ..."
},
"services": {
"sing-in-with-prefix": "Ŝįģʼn įʼn ŵįŧĥ {{serviceName}}"

Loading…
Cancel
Save