diff --git a/public/app/core/components/Login/LoginCtrl.tsx b/public/app/core/components/Login/LoginCtrl.tsx index 468353aac32..611572bae0a 100644 --- a/public/app/core/components/Login/LoginCtrl.tsx +++ b/public/app/core/components/Login/LoginCtrl.tsx @@ -1,9 +1,10 @@ import React, { PureComponent } from 'react'; import { AppEvents } from '@grafana/data'; -import { getBackendSrv } from '@grafana/runtime'; +import { FetchError, getBackendSrv, isFetchError } from '@grafana/runtime'; import appEvents from 'app/core/app_events'; import config from 'app/core/config'; +import { t } from 'app/core/internationalization'; import { LoginDTO } from './types'; @@ -32,6 +33,7 @@ interface Props { loginHint: string; passwordHint: string; showDefaultPasswordWarning: boolean; + loginErrorMessage: string | undefined; }) => JSX.Element; } @@ -39,6 +41,7 @@ interface State { isLoggingIn: boolean; isChangingPassword: boolean; showDefaultPasswordWarning: boolean; + loginErrorMessage?: string; } export class LoginCtrl extends PureComponent { @@ -88,11 +91,12 @@ export class LoginCtrl extends PureComponent { login = (formModel: FormModel) => { this.setState({ + loginErrorMessage: undefined, isLoggingIn: true, }); getBackendSrv() - .post('/login', formModel) + .post('/login', formModel, { showErrorAlert: false }) .then((result) => { this.result = result; if (formModel.password !== 'admin' || config.ldapEnabled || config.authProxyEnabled) { @@ -102,9 +106,11 @@ export class LoginCtrl extends PureComponent { this.changeView(formModel.password === 'admin'); } }) - .catch(() => { + .catch((err) => { + const fetchErrorMessage = isFetchError(err) ? getErrorMessage(err) : undefined; this.setState({ isLoggingIn: false, + loginErrorMessage: fetchErrorMessage || t('login.error.unknown', 'Unknown error occurred'), }); }); }; @@ -131,7 +137,7 @@ export class LoginCtrl extends PureComponent { render() { const { children } = this.props; - const { isLoggingIn, isChangingPassword, showDefaultPasswordWarning } = this.state; + const { isLoggingIn, isChangingPassword, showDefaultPasswordWarning, loginErrorMessage } = this.state; const { login, toGrafana, changePassword } = this; const { loginHint, passwordHint, disableLoginForm, disableUserSignUp } = config; @@ -149,6 +155,7 @@ export class LoginCtrl extends PureComponent { skipPasswordChange: toGrafana, isChangingPassword, showDefaultPasswordWarning, + loginErrorMessage, })} ); @@ -156,3 +163,15 @@ export class LoginCtrl extends PureComponent { } export default LoginCtrl; + +function getErrorMessage(err: FetchError): string | undefined { + switch (err.data?.messageId) { + case 'password-auth.empty': + case 'password-auth.failed': + case 'password-auth.invalid': + case 'login-attempt.blocked': + return t('login.error.invalid-user-or-password', 'Invalid username or password'); + default: + return err.data?.message; + } +} diff --git a/public/app/core/components/Login/LoginPage.test.tsx b/public/app/core/components/Login/LoginPage.test.tsx index 0cc29232eb0..a773abbe4d7 100644 --- a/public/app/core/components/Login/LoginPage.test.tsx +++ b/public/app/core/components/Login/LoginPage.test.tsx @@ -51,6 +51,7 @@ describe('Login Page', () => { expect(screen.getByRole('link', { name: 'Sign up' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Sign up' })).toHaveAttribute('href', '/signup'); }); + it('should pass validation checks for username field', async () => { render(); @@ -60,6 +61,7 @@ describe('Login Page', () => { await userEvent.type(screen.getByRole('textbox', { name: 'Username input field' }), 'admin'); await waitFor(() => expect(screen.queryByText('Email or username is required')).not.toBeInTheDocument()); }); + it('should pass validation checks for password field', async () => { render(); @@ -69,6 +71,7 @@ describe('Login Page', () => { await userEvent.type(screen.getByLabelText('Password input field'), 'admin'); await waitFor(() => expect(screen.queryByText('Password is required')).not.toBeInTheDocument()); }); + it('should navigate to default url if credentials is valid', async () => { Object.defineProperty(window, 'location', { value: { @@ -82,9 +85,12 @@ describe('Login Page', () => { await userEvent.type(screen.getByLabelText('Password input field'), 'test'); fireEvent.click(screen.getByLabelText('Login button')); - await waitFor(() => expect(postMock).toHaveBeenCalledWith('/login', { password: 'test', user: 'admin' })); + await waitFor(() => + expect(postMock).toHaveBeenCalledWith('/login', { password: 'test', user: 'admin' }, { showErrorAlert: false }) + ); expect(window.location.assign).toHaveBeenCalledWith('/'); }); + it('renders social logins correctly', () => { runtimeMock.config.oauth = { okta: { diff --git a/public/app/core/components/Login/LoginPage.tsx b/public/app/core/components/Login/LoginPage.tsx index 58dd82b83fe..e19aa78cb85 100644 --- a/public/app/core/components/Login/LoginPage.tsx +++ b/public/app/core/components/Login/LoginPage.tsx @@ -3,7 +3,7 @@ import { css } from '@emotion/css'; import React from 'react'; // Components -import { HorizontalGroup, LinkButton } from '@grafana/ui'; +import { Alert, HorizontalGroup, LinkButton } from '@grafana/ui'; import { Branding } from 'app/core/components/Branding/Branding'; import config from 'app/core/config'; @@ -20,6 +20,10 @@ const forgottenPasswordStyles = css` margin-top: 4px; `; +const alertStyles = css({ + width: '100%', +}); + export const LoginPage = () => { document.title = Branding.AppTitle; return ( @@ -35,10 +39,13 @@ export const LoginPage = () => { skipPasswordChange, isChangingPassword, showDefaultPasswordWarning, + loginErrorMessage, }) => ( {!isChangingPassword && ( + {loginErrorMessage && } + {!disableLoginForm && ( @@ -56,6 +63,7 @@ export const LoginPage = () => { {!disableUserSignUp && } )} + {isChangingPassword && (