mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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>
This commit is contained in:
parent
c865958292
commit
6abe99efd6
@ -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
|
||||
|
51
emails/templates/passwordless_verify_existing_user.mjml
Normal file
51
emails/templates/passwordless_verify_existing_user.mjml
Normal file
@ -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>
|
10
emails/templates/passwordless_verify_existing_user.txt
Normal file
10
emails/templates/passwordless_verify_existing_user.txt
Normal file
@ -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]]
|
53
emails/templates/passwordless_verify_new_user.mjml
Normal file
53
emails/templates/passwordless_verify_new_user.mjml
Normal file
@ -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>
|
10
emails/templates/passwordless_verify_new_user.txt
Normal file
10
emails/templates/passwordless_verify_new_user.txt
Normal file
@ -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 {
|
||||
|
335
pkg/services/authn/clients/passwordless.go
Normal file
335
pkg/services/authn/clients/passwordless.go
Normal file
@ -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
|
||||
}
|
160
pkg/services/authn/clients/passwordless_test.go
Normal file
160
pkg/services/authn/clients/passwordless_test.go
Normal file
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
17
pkg/setting/setting_passwordless_magic_link.go
Normal file
17
pkg/setting/setting_passwordless_magic_link.go
Normal file
@ -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',
|
||||
}),
|
||||
};
|
||||
};
|
70
public/app/core/components/Login/PasswordlessLoginForm.tsx
Normal file
70
public/app/core/components/Login/PasswordlessLoginForm.tsx
Normal file
@ -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;
|
||||
}
|
||||
|
273
public/emails/passwordless_verify_existing_user.html
Normal file
273
public/emails/passwordless_verify_existing_user.html
Normal file
@ -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>
|
12
public/emails/passwordless_verify_existing_user.txt
Normal file
12
public/emails/passwordless_verify_existing_user.txt
Normal file
@ -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]]
|
273
public/emails/passwordless_verify_new_user.html
Normal file
273
public/emails/passwordless_verify_new_user.html
Normal file
@ -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>
|
12
public/emails/passwordless_verify_new_user.txt
Normal file
12
public/emails/passwordless_verify_new_user.txt
Normal file
@ -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…
Reference in New Issue
Block a user