diff --git a/pkg/api/login.go b/pkg/api/login.go index 50cdeaefb22..4ee917e9fd9 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -283,7 +283,7 @@ func (hs *HTTPServer) Logout(c *models.ReqContext) { return } - err := hs.AuthTokenService.RevokeToken(c.Req.Context(), c.UserToken) + err := hs.AuthTokenService.RevokeToken(c.Req.Context(), c.UserToken, false) if err != nil && !errors.Is(err, models.ErrUserTokenNotFound) { hs.log.Error("failed to revoke auth token", "error", err) } diff --git a/pkg/api/user_token.go b/pkg/api/user_token.go index c143e5e6089..257d60c1ab7 100644 --- a/pkg/api/user_token.go +++ b/pkg/api/user_token.go @@ -132,7 +132,7 @@ func (hs *HTTPServer) revokeUserAuthTokenInternal(c *models.ReqContext, userID i return response.Error(400, "Cannot revoke active user auth token", nil) } - err = hs.AuthTokenService.RevokeToken(c.Req.Context(), token) + err = hs.AuthTokenService.RevokeToken(c.Req.Context(), token, false) if err != nil { if errors.Is(err, models.ErrUserTokenNotFound) { return response.Error(404, "User auth token not found", err) diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index 513d74e8c75..8841250eafe 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -1,6 +1,7 @@ package middleware import ( + "errors" "net/url" "regexp" "strconv" @@ -34,6 +35,27 @@ func notAuthorized(c *models.ReqContext) { return } + writeRedirectCookie(c) + c.Redirect(setting.AppSubUrl + "/login") +} + +func tokenRevoked(c *models.ReqContext, err *models.TokenRevokedError) { + if c.IsApiRequest() { + c.JSON(401, map[string]interface{}{ + "message": "Token revoked", + "error": map[string]interface{}{ + "id": "ERR_TOKEN_REVOKED", + "maxConcurrentSessions": err.MaxConcurrentSessions, + }, + }) + return + } + + writeRedirectCookie(c) + c.Redirect(setting.AppSubUrl + "/login") +} + +func writeRedirectCookie(c *models.ReqContext) { redirectTo := c.Req.RequestURI if setting.AppSubUrl != "" && !strings.HasPrefix(redirectTo, setting.AppSubUrl) { redirectTo = setting.AppSubUrl + c.Req.RequestURI @@ -43,7 +65,6 @@ func notAuthorized(c *models.ReqContext) { redirectTo = removeForceLoginParams(redirectTo) cookies.WriteCookie(c.Resp, "redirect_to", url.QueryEscape(redirectTo), 0, nil) - c.Redirect(setting.AppSubUrl + "/login") } var forceLoginParamsRegexp = regexp.MustCompile(`&?forceLogin=true`) @@ -90,6 +111,13 @@ func Auth(options *AuthOptions) macaron.Handler { requireLogin := !c.AllowAnonymous || forceLogin || options.ReqNoAnonynmous if !c.IsSignedIn && options.ReqSignedIn && requireLogin { + lookupTokenErr, hasTokenErr := c.Data["lookupTokenErr"].(error) + var revokedErr *models.TokenRevokedError + if hasTokenErr && errors.As(lookupTokenErr, &revokedErr) { + tokenRevoked(c, revokedErr) + return + } + notAuthorized(c) return } diff --git a/pkg/models/user_token.go b/pkg/models/user_token.go index 3cba9ad4595..f31c6da67e3 100644 --- a/pkg/models/user_token.go +++ b/pkg/models/user_token.go @@ -32,6 +32,14 @@ type TokenExpiredError struct { func (e *TokenExpiredError) Error() string { return "user token expired" } +type TokenRevokedError struct { + UserID int64 + TokenID int64 + MaxConcurrentSessions int64 +} + +func (e *TokenRevokedError) Error() string { return "user token revoked" } + // UserToken represents a user token type UserToken struct { Id int64 @@ -45,6 +53,7 @@ type UserToken struct { RotatedAt int64 CreatedAt int64 UpdatedAt int64 + RevokedAt int64 UnhashedToken string } @@ -57,9 +66,10 @@ type UserTokenService interface { CreateToken(ctx context.Context, user *User, clientIP net.IP, userAgent string) (*UserToken, error) LookupToken(ctx context.Context, unhashedToken string) (*UserToken, error) TryRotateToken(ctx context.Context, token *UserToken, clientIP net.IP, userAgent string) (bool, error) - RevokeToken(ctx context.Context, token *UserToken) error + RevokeToken(ctx context.Context, token *UserToken, soft bool) error RevokeAllUserTokens(ctx context.Context, userId int64) error ActiveTokenCount(ctx context.Context) (int64, error) GetUserToken(ctx context.Context, userId, userTokenId int64) (*UserToken, error) GetUserTokens(ctx context.Context, userId int64) ([]*UserToken, error) + GetUserRevokedTokens(ctx context.Context, userId int64) ([]*UserToken, error) } diff --git a/pkg/services/auth/auth_token.go b/pkg/services/auth/auth_token.go index 0490f9b9d8b..2e3d8f5ae30 100644 --- a/pkg/services/auth/auth_token.go +++ b/pkg/services/auth/auth_token.go @@ -49,7 +49,7 @@ func (s *UserAuthTokenService) ActiveTokenCount(ctx context.Context) (int64, err var err error err = s.SQLStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error { var model userAuthToken - count, err = dbSession.Where(`created_at > ? AND rotated_at > ?`, + count, err = dbSession.Where(`created_at > ? AND rotated_at > ? AND revoked_at = 0`, s.createdAfterParam(), s.rotatedAfterParam()). Count(&model) @@ -84,6 +84,7 @@ func (s *UserAuthTokenService) CreateToken(ctx context.Context, user *models.Use CreatedAt: now, UpdatedAt: now, SeenAt: 0, + RevokedAt: 0, AuthTokenSeen: false, } @@ -127,6 +128,13 @@ func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken st return nil, models.ErrUserTokenNotFound } + if model.RevokedAt > 0 { + return nil, &models.TokenRevokedError{ + UserID: model.UserId, + TokenID: model.Id, + } + } + if model.CreatedAt <= s.createdAfterParam() || model.RotatedAt <= s.rotatedAfterParam() { return nil, &models.TokenExpiredError{ UserID: model.UserId, @@ -278,7 +286,7 @@ func (s *UserAuthTokenService) TryRotateToken(ctx context.Context, token *models return false, nil } -func (s *UserAuthTokenService) RevokeToken(ctx context.Context, token *models.UserToken) error { +func (s *UserAuthTokenService) RevokeToken(ctx context.Context, token *models.UserToken, soft bool) error { if token == nil { return models.ErrUserTokenNotFound } @@ -289,10 +297,19 @@ func (s *UserAuthTokenService) RevokeToken(ctx context.Context, token *models.Us } var rowsAffected int64 - err = s.SQLStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error { - rowsAffected, err = dbSession.Delete(model) - return err - }) + + if soft { + model.RevokedAt = getTime().Unix() + err = s.SQLStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error { + rowsAffected, err = dbSession.ID(model.Id).Update(model) + return err + }) + } else { + err = s.SQLStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error { + rowsAffected, err = dbSession.Delete(model) + return err + }) + } if err != nil { return err @@ -303,7 +320,7 @@ func (s *UserAuthTokenService) RevokeToken(ctx context.Context, token *models.Us return models.ErrUserTokenNotFound } - s.log.Debug("user auth token revoked", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent) + s.log.Debug("user auth token revoked", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent, "soft", soft) return nil } @@ -380,7 +397,7 @@ func (s *UserAuthTokenService) GetUserTokens(ctx context.Context, userId int64) result := []*models.UserToken{} err := s.SQLStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error { var tokens []*userAuthToken - err := dbSession.Where("user_id = ? AND created_at > ? AND rotated_at > ?", + err := dbSession.Where("user_id = ? AND created_at > ? AND rotated_at > ? AND revoked_at = 0", userId, s.createdAfterParam(), s.rotatedAfterParam()). @@ -403,6 +420,29 @@ func (s *UserAuthTokenService) GetUserTokens(ctx context.Context, userId int64) return result, err } +func (s *UserAuthTokenService) GetUserRevokedTokens(ctx context.Context, userId int64) ([]*models.UserToken, error) { + result := []*models.UserToken{} + err := s.SQLStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error { + var tokens []*userAuthToken + err := dbSession.Where("user_id = ? AND revoked_at > 0", userId).Find(&tokens) + if err != nil { + return err + } + + for _, token := range tokens { + var userToken models.UserToken + if err := token.toUserToken(&userToken); err != nil { + return err + } + result = append(result, &userToken) + } + + return nil + }) + + return result, err +} + func (s *UserAuthTokenService) createdAfterParam() int64 { return getTime().Add(-s.Cfg.LoginMaxLifetime).Unix() } diff --git a/pkg/services/auth/auth_token_test.go b/pkg/services/auth/auth_token_test.go index 7e71464158c..6e9e97cba14 100644 --- a/pkg/services/auth/auth_token_test.go +++ b/pkg/services/auth/auth_token_test.go @@ -60,8 +60,18 @@ func TestUserAuthToken(t *testing.T) { So(userToken, ShouldBeNil) }) - Convey("revoking existing token should delete token", func() { - err = userAuthTokenService.RevokeToken(context.Background(), userToken) + Convey("soft revoking existing token should not delete it", func() { + err = userAuthTokenService.RevokeToken(context.Background(), userToken, true) + So(err, ShouldBeNil) + + model, err := ctx.getAuthTokenByID(userToken.Id) + So(err, ShouldBeNil) + So(model, ShouldNotBeNil) + So(model.RevokedAt, ShouldBeGreaterThan, 0) + }) + + Convey("revoking existing token should delete it", func() { + err = userAuthTokenService.RevokeToken(context.Background(), userToken, false) So(err, ShouldBeNil) model, err := ctx.getAuthTokenByID(userToken.Id) @@ -70,13 +80,13 @@ func TestUserAuthToken(t *testing.T) { }) Convey("revoking nil token should return error", func() { - err = userAuthTokenService.RevokeToken(context.Background(), nil) + err = userAuthTokenService.RevokeToken(context.Background(), nil, false) So(err, ShouldEqual, models.ErrUserTokenNotFound) }) Convey("revoking non-existing token should return error", func() { userToken.Id = 1000 - err = userAuthTokenService.RevokeToken(context.Background(), userToken) + err = userAuthTokenService.RevokeToken(context.Background(), userToken, false) So(err, ShouldEqual, models.ErrUserTokenNotFound) }) diff --git a/pkg/services/auth/model.go b/pkg/services/auth/model.go index 2948361a70c..799b3e68b16 100644 --- a/pkg/services/auth/model.go +++ b/pkg/services/auth/model.go @@ -18,6 +18,7 @@ type userAuthToken struct { RotatedAt int64 CreatedAt int64 UpdatedAt int64 + RevokedAt int64 UnhashedToken string `xorm:"-"` } @@ -43,6 +44,7 @@ func (uat *userAuthToken) fromUserToken(ut *models.UserToken) error { uat.RotatedAt = ut.RotatedAt uat.CreatedAt = ut.CreatedAt uat.UpdatedAt = ut.UpdatedAt + uat.RevokedAt = ut.RevokedAt uat.UnhashedToken = ut.UnhashedToken return nil @@ -64,6 +66,7 @@ func (uat *userAuthToken) toUserToken(ut *models.UserToken) error { ut.RotatedAt = uat.RotatedAt ut.CreatedAt = uat.CreatedAt ut.UpdatedAt = uat.UpdatedAt + ut.RevokedAt = uat.RevokedAt ut.UnhashedToken = uat.UnhashedToken return nil diff --git a/pkg/services/auth/testing.go b/pkg/services/auth/testing.go index 44fd674a6db..3985646a5ec 100644 --- a/pkg/services/auth/testing.go +++ b/pkg/services/auth/testing.go @@ -8,15 +8,16 @@ import ( ) type FakeUserAuthTokenService struct { - CreateTokenProvider func(ctx context.Context, user *models.User, clientIP net.IP, userAgent string) (*models.UserToken, error) - TryRotateTokenProvider func(ctx context.Context, token *models.UserToken, clientIP net.IP, userAgent string) (bool, error) - LookupTokenProvider func(ctx context.Context, unhashedToken string) (*models.UserToken, error) - RevokeTokenProvider func(ctx context.Context, token *models.UserToken) error - RevokeAllUserTokensProvider func(ctx context.Context, userId int64) error - ActiveAuthTokenCount func(ctx context.Context) (int64, error) - GetUserTokenProvider func(ctx context.Context, userId, userTokenId int64) (*models.UserToken, error) - GetUserTokensProvider func(ctx context.Context, userId int64) ([]*models.UserToken, error) - BatchRevokedTokenProvider func(ctx context.Context, userIds []int64) error + CreateTokenProvider func(ctx context.Context, user *models.User, clientIP net.IP, userAgent string) (*models.UserToken, error) + TryRotateTokenProvider func(ctx context.Context, token *models.UserToken, clientIP net.IP, userAgent string) (bool, error) + LookupTokenProvider func(ctx context.Context, unhashedToken string) (*models.UserToken, error) + RevokeTokenProvider func(ctx context.Context, token *models.UserToken, soft bool) error + RevokeAllUserTokensProvider func(ctx context.Context, userId int64) error + ActiveAuthTokenCount func(ctx context.Context) (int64, error) + GetUserTokenProvider func(ctx context.Context, userId, userTokenId int64) (*models.UserToken, error) + GetUserTokensProvider func(ctx context.Context, userId int64) ([]*models.UserToken, error) + GetUserRevokedTokensProvider func(ctx context.Context, userId int64) ([]*models.UserToken, error) + BatchRevokedTokenProvider func(ctx context.Context, userIds []int64) error } func NewFakeUserAuthTokenService() *FakeUserAuthTokenService { @@ -36,7 +37,7 @@ func NewFakeUserAuthTokenService() *FakeUserAuthTokenService { UnhashedToken: "", }, nil }, - RevokeTokenProvider: func(ctx context.Context, token *models.UserToken) error { + RevokeTokenProvider: func(ctx context.Context, token *models.UserToken, soft bool) error { return nil }, RevokeAllUserTokensProvider: func(ctx context.Context, userId int64) error { @@ -76,8 +77,8 @@ func (s *FakeUserAuthTokenService) TryRotateToken(ctx context.Context, token *mo return s.TryRotateTokenProvider(context.Background(), token, clientIP, userAgent) } -func (s *FakeUserAuthTokenService) RevokeToken(ctx context.Context, token *models.UserToken) error { - return s.RevokeTokenProvider(context.Background(), token) +func (s *FakeUserAuthTokenService) RevokeToken(ctx context.Context, token *models.UserToken, soft bool) error { + return s.RevokeTokenProvider(context.Background(), token, soft) } func (s *FakeUserAuthTokenService) RevokeAllUserTokens(ctx context.Context, userId int64) error { @@ -96,6 +97,10 @@ func (s *FakeUserAuthTokenService) GetUserTokens(ctx context.Context, userId int return s.GetUserTokensProvider(context.Background(), userId) } +func (s *FakeUserAuthTokenService) GetUserRevokedTokens(ctx context.Context, userId int64) ([]*models.UserToken, error) { + return s.GetUserRevokedTokensProvider(context.Background(), userId) +} + func (s *FakeUserAuthTokenService) BatchRevokeAllUserTokens(ctx context.Context, userIds []int64) error { return s.BatchRevokedTokenProvider(ctx, userIds) } diff --git a/pkg/services/contexthandler/contexthandler.go b/pkg/services/contexthandler/contexthandler.go index 29ff730d874..d00e303c81b 100644 --- a/pkg/services/contexthandler/contexthandler.go +++ b/pkg/services/contexthandler/contexthandler.go @@ -257,7 +257,11 @@ func (h *ContextHandler) initContextWithToken(ctx *models.ReqContext, orgID int6 token, err := h.AuthTokenService.LookupToken(ctx.Req.Context(), rawToken) if err != nil { ctx.Logger.Error("Failed to look up user based on cookie", "error", err) - cookies.WriteSessionCookie(ctx, h.Cfg, "", -1) + + var revokedErr *models.TokenRevokedError + if !errors.As(err, &revokedErr) || !ctx.IsApiRequest() { + cookies.WriteSessionCookie(ctx, h.Cfg, "", -1) + } ctx.Data["lookupTokenErr"] = err return false diff --git a/pkg/services/sqlstore/migrations/user_auth_token_mig.go b/pkg/services/sqlstore/migrations/user_auth_token_mig.go index 990d549002a..f702bd97100 100644 --- a/pkg/services/sqlstore/migrations/user_auth_token_mig.go +++ b/pkg/services/sqlstore/migrations/user_auth_token_mig.go @@ -32,4 +32,16 @@ func addUserAuthTokenMigrations(mg *Migrator) { mg.AddMigration("add unique index user_auth_token.prev_auth_token", NewAddIndexMigration(userAuthTokenV1, userAuthTokenV1.Indices[1])) mg.AddMigration("add index user_auth_token.user_id", NewAddIndexMigration(userAuthTokenV1, userAuthTokenV1.Indices[2])) + + mg.AddMigration( + "Add revoked_at to the user auth token", + NewAddColumnMigration( + userAuthTokenV1, + &Column{ + Name: "revoked_at", + Type: DB_Int, + Nullable: true, + }, + ), + ) } diff --git a/public/app/core/services/backend_srv.ts b/public/app/core/services/backend_srv.ts index ac61f6bd3f6..1d2d6fab73c 100644 --- a/public/app/core/services/backend_srv.ts +++ b/public/app/core/services/backend_srv.ts @@ -1,4 +1,4 @@ -import { from, merge, MonoTypeOperatorFunction, Observable, Subject, Subscription, throwError } from 'rxjs'; +import { from, merge, MonoTypeOperatorFunction, Observable, of, Subject, Subscription, throwError } from 'rxjs'; import { catchError, filter, map, mergeMap, retryWhen, share, takeUntil, tap, throwIfEmpty } from 'rxjs/operators'; import { fromFetch } from 'rxjs/fetch'; import { v4 as uuidv4 } from 'uuid'; @@ -8,7 +8,7 @@ import { AppEvents, DataQueryErrorType } from '@grafana/data'; import appEvents from 'app/core/app_events'; import { getConfig } from 'app/core/config'; import { DashboardSearchHit } from 'app/features/search/types'; -import { FolderDTO } from 'app/types'; +import { CoreEvents, FolderDTO } from 'app/types'; import { coreModule } from 'app/core/core_module'; import { ContextSrv, contextSrv } from './context_srv'; import { parseInitFromOptions, parseResponseBody, parseUrlFromOptions } from '../utils/fetch'; @@ -16,6 +16,7 @@ import { isDataQuery, isLocalUrl } from '../utils/query'; import { FetchQueue } from './FetchQueue'; import { ResponseQueue } from './ResponseQueue'; import { FetchQueueWorker } from './FetchQueueWorker'; +import { TokenRevokedModal } from 'app/features/users/TokenRevokedModal'; const CANCEL_ALL_REQUESTS_REQUEST_ID = 'cancel_all_requests_request_id'; @@ -212,6 +213,17 @@ export class BackendSrv implements BackendService { const firstAttempt = i === 0 && options.retry === 0; if (error.status === 401 && isLocalUrl(options.url) && firstAttempt && isSignedIn) { + if (error.data?.error?.id === 'ERR_TOKEN_REVOKED') { + this.dependencies.appEvents.emit(CoreEvents.showModalReact, { + component: TokenRevokedModal, + props: { + maxConcurrentSessions: error.data?.error?.maxConcurrentSessions, + }, + }); + + return of({}); + } + return from(this.loginPing()).pipe( catchError((err) => { if (err.status === 401) { diff --git a/public/app/core/specs/backend_srv.test.ts b/public/app/core/specs/backend_srv.test.ts index b0fc1639f79..019791dd1e3 100644 --- a/public/app/core/specs/backend_srv.test.ts +++ b/public/app/core/specs/backend_srv.test.ts @@ -7,6 +7,8 @@ import { BackendSrv } from '../services/backend_srv'; import { ContextSrv, User } from '../services/context_srv'; import { describe, expect } from '../../../test/lib/common'; import { BackendSrvRequest, FetchError } from '@grafana/runtime'; +import { TokenRevokedModal } from '../../features/users/TokenRevokedModal'; +import { CoreEvents } from '../../types'; const getTestContext = (overides?: object) => { const defaults = { @@ -185,6 +187,34 @@ describe('backendSrv', () => { }); }); + describe('when making an unsuccessful call because of soft token revocation', () => { + it('then it should dispatch show Token Revoked modal event', async () => { + const url = '/api/dashboard/'; + const { backendSrv, appEventsMock, logoutMock, expectRequestCallChain } = getTestContext({ + ok: false, + status: 401, + statusText: 'UnAuthorized', + data: { message: 'Token revoked', error: { id: 'ERR_TOKEN_REVOKED', maxConcurrentSessions: 3 } }, + url, + }); + + backendSrv.loginPing = jest.fn(); + + await backendSrv.request({ url, method: 'GET', retry: 0 }).catch(() => { + expect(appEventsMock.emit).toHaveBeenCalledTimes(1); + expect(appEventsMock.emit).toHaveBeenCalledWith(CoreEvents.showModalReact, { + component: TokenRevokedModal, + props: { + maxConcurrentSessions: 3, + }, + }); + expect(backendSrv.loginPing).not.toHaveBeenCalled(); + expect(logoutMock).not.toHaveBeenCalled(); + expectRequestCallChain({ url, method: 'GET', retry: 0 }); + }); + }); + }); + describe('when making an unsuccessful call and conditions for retry are favorable and retry throws', () => { it('then it throw error', async () => { jest.useFakeTimers(); @@ -394,6 +424,34 @@ describe('backendSrv', () => { }); }); + describe('when making an unsuccessful call because of soft token revocation', () => { + it('then it should dispatch show Token Revoked modal event', async () => { + const { backendSrv, logoutMock, appEventsMock, expectRequestCallChain } = getTestContext({ + ok: false, + status: 401, + statusText: 'UnAuthorized', + data: { message: 'Token revoked', error: { id: 'ERR_TOKEN_REVOKED', maxConcurrentSessions: 3 } }, + }); + + backendSrv.loginPing = jest.fn(); + + const url = '/api/dashboard/'; + + await backendSrv.datasourceRequest({ url, method: 'GET', retry: 0 }).catch(() => { + expect(appEventsMock.emit).toHaveBeenCalledTimes(1); + expect(appEventsMock.emit).toHaveBeenCalledWith(CoreEvents.showModalReact, { + component: TokenRevokedModal, + props: { + maxConcurrentSessions: 3, + }, + }); + expect(backendSrv.loginPing).not.toHaveBeenCalled(); + expect(logoutMock).not.toHaveBeenCalled(); + expectRequestCallChain({ url, method: 'GET', retry: 0 }); + }); + }); + }); + describe('when making an unsuccessful call and conditions for retry are favorable and retry throws', () => { it('then it throw error', async () => { const { backendSrv, logoutMock, expectRequestCallChain } = getTestContext({ diff --git a/public/app/features/users/TokenRevokedModal.tsx b/public/app/features/users/TokenRevokedModal.tsx new file mode 100644 index 00000000000..cea1bcb9773 --- /dev/null +++ b/public/app/features/users/TokenRevokedModal.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { Button, InfoBox, Portal, stylesFactory, useTheme } from '@grafana/ui'; +import { getModalStyles } from '@grafana/ui/src/components/Modal/getModalStyles'; +import { css, cx } from 'emotion'; +import { GrafanaTheme } from '@grafana/data'; + +interface Props { + maxConcurrentSessions?: number; +} + +export const TokenRevokedModal = (props: Props) => { + const theme = useTheme(); + + const styles = getStyles(theme); + const modalStyles = getModalStyles(theme); + + const showMaxConcurrentSessions = Boolean(props.maxConcurrentSessions); + + const redirectToLogin = () => { + window.location.reload(); + }; + + return ( + +
+ +
+

+ Your session token was automatically revoked because you have reached + + {` the maximum number of ${ + showMaxConcurrentSessions ? props.maxConcurrentSessions : '' + } concurrent sessions `} + + for your account. +

+

+ To resume your session, sign in again. + Contact your administrator or visit the license page to review your quota if you are repeatedly signed out + automatically. +

+
+ +
+
+
+ + ); +}; + +const getStyles = stylesFactory((theme: GrafanaTheme) => { + return { + infobox: css` + margin-bottom: 0; + `, + text: css` + margin: ${theme.spacing.sm} 0 ${theme.spacing.md}; + `, + backdrop: css` + background-color: ${theme.colors.dashboardBg}; + opacity: 0.8; + `, + }; +});