Login: Show error messages inline in form instead of in toasts (#70266)

* Login: Show error messages inline in form instead of in toasts

* remove console.log lol

* fix test
This commit is contained in:
Josh Hunt 2023-06-21 11:28:20 +01:00 committed by GitHub
parent 31b9f9d235
commit e9d42a6395
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 75 additions and 6 deletions

View File

@ -1,9 +1,10 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { AppEvents } from '@grafana/data'; 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 appEvents from 'app/core/app_events';
import config from 'app/core/config'; import config from 'app/core/config';
import { t } from 'app/core/internationalization';
import { LoginDTO } from './types'; import { LoginDTO } from './types';
@ -32,6 +33,7 @@ interface Props {
loginHint: string; loginHint: string;
passwordHint: string; passwordHint: string;
showDefaultPasswordWarning: boolean; showDefaultPasswordWarning: boolean;
loginErrorMessage: string | undefined;
}) => JSX.Element; }) => JSX.Element;
} }
@ -39,6 +41,7 @@ interface State {
isLoggingIn: boolean; isLoggingIn: boolean;
isChangingPassword: boolean; isChangingPassword: boolean;
showDefaultPasswordWarning: boolean; showDefaultPasswordWarning: boolean;
loginErrorMessage?: string;
} }
export class LoginCtrl extends PureComponent<Props, State> { export class LoginCtrl extends PureComponent<Props, State> {
@ -88,11 +91,12 @@ export class LoginCtrl extends PureComponent<Props, State> {
login = (formModel: FormModel) => { login = (formModel: FormModel) => {
this.setState({ this.setState({
loginErrorMessage: undefined,
isLoggingIn: true, isLoggingIn: true,
}); });
getBackendSrv() getBackendSrv()
.post<LoginDTO>('/login', formModel) .post<LoginDTO>('/login', formModel, { showErrorAlert: false })
.then((result) => { .then((result) => {
this.result = result; this.result = result;
if (formModel.password !== 'admin' || config.ldapEnabled || config.authProxyEnabled) { if (formModel.password !== 'admin' || config.ldapEnabled || config.authProxyEnabled) {
@ -102,9 +106,11 @@ export class LoginCtrl extends PureComponent<Props, State> {
this.changeView(formModel.password === 'admin'); this.changeView(formModel.password === 'admin');
} }
}) })
.catch(() => { .catch((err) => {
const fetchErrorMessage = isFetchError(err) ? getErrorMessage(err) : undefined;
this.setState({ this.setState({
isLoggingIn: false, isLoggingIn: false,
loginErrorMessage: fetchErrorMessage || t('login.error.unknown', 'Unknown error occurred'),
}); });
}); });
}; };
@ -131,7 +137,7 @@ export class LoginCtrl extends PureComponent<Props, State> {
render() { render() {
const { children } = this.props; const { children } = this.props;
const { isLoggingIn, isChangingPassword, showDefaultPasswordWarning } = this.state; const { isLoggingIn, isChangingPassword, showDefaultPasswordWarning, loginErrorMessage } = this.state;
const { login, toGrafana, changePassword } = this; const { login, toGrafana, changePassword } = this;
const { loginHint, passwordHint, disableLoginForm, disableUserSignUp } = config; const { loginHint, passwordHint, disableLoginForm, disableUserSignUp } = config;
@ -149,6 +155,7 @@ export class LoginCtrl extends PureComponent<Props, State> {
skipPasswordChange: toGrafana, skipPasswordChange: toGrafana,
isChangingPassword, isChangingPassword,
showDefaultPasswordWarning, showDefaultPasswordWarning,
loginErrorMessage,
})} })}
</> </>
); );
@ -156,3 +163,15 @@ export class LoginCtrl extends PureComponent<Props, State> {
} }
export default LoginCtrl; export default LoginCtrl;
function getErrorMessage(err: FetchError<undefined | { messageId?: string; message?: string }>): 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;
}
}

View File

@ -51,6 +51,7 @@ describe('Login Page', () => {
expect(screen.getByRole('link', { name: 'Sign up' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Sign up' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Sign up' })).toHaveAttribute('href', '/signup'); expect(screen.getByRole('link', { name: 'Sign up' })).toHaveAttribute('href', '/signup');
}); });
it('should pass validation checks for username field', async () => { it('should pass validation checks for username field', async () => {
render(<LoginPage />); render(<LoginPage />);
@ -60,6 +61,7 @@ describe('Login Page', () => {
await userEvent.type(screen.getByRole('textbox', { name: 'Username input field' }), 'admin'); await userEvent.type(screen.getByRole('textbox', { name: 'Username input field' }), 'admin');
await waitFor(() => expect(screen.queryByText('Email or username is required')).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByText('Email or username is required')).not.toBeInTheDocument());
}); });
it('should pass validation checks for password field', async () => { it('should pass validation checks for password field', async () => {
render(<LoginPage />); render(<LoginPage />);
@ -69,6 +71,7 @@ describe('Login Page', () => {
await userEvent.type(screen.getByLabelText('Password input field'), 'admin'); await userEvent.type(screen.getByLabelText('Password input field'), 'admin');
await waitFor(() => expect(screen.queryByText('Password is required')).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByText('Password is required')).not.toBeInTheDocument());
}); });
it('should navigate to default url if credentials is valid', async () => { it('should navigate to default url if credentials is valid', async () => {
Object.defineProperty(window, 'location', { Object.defineProperty(window, 'location', {
value: { value: {
@ -82,9 +85,12 @@ describe('Login Page', () => {
await userEvent.type(screen.getByLabelText('Password input field'), 'test'); await userEvent.type(screen.getByLabelText('Password input field'), 'test');
fireEvent.click(screen.getByLabelText('Login button')); 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('/'); expect(window.location.assign).toHaveBeenCalledWith('/');
}); });
it('renders social logins correctly', () => { it('renders social logins correctly', () => {
runtimeMock.config.oauth = { runtimeMock.config.oauth = {
okta: { okta: {

View File

@ -3,7 +3,7 @@ import { css } from '@emotion/css';
import React from 'react'; import React from 'react';
// Components // Components
import { HorizontalGroup, LinkButton } from '@grafana/ui'; import { Alert, HorizontalGroup, LinkButton } from '@grafana/ui';
import { Branding } from 'app/core/components/Branding/Branding'; import { Branding } from 'app/core/components/Branding/Branding';
import config from 'app/core/config'; import config from 'app/core/config';
@ -20,6 +20,10 @@ const forgottenPasswordStyles = css`
margin-top: 4px; margin-top: 4px;
`; `;
const alertStyles = css({
width: '100%',
});
export const LoginPage = () => { export const LoginPage = () => {
document.title = Branding.AppTitle; document.title = Branding.AppTitle;
return ( return (
@ -35,10 +39,13 @@ export const LoginPage = () => {
skipPasswordChange, skipPasswordChange,
isChangingPassword, isChangingPassword,
showDefaultPasswordWarning, showDefaultPasswordWarning,
loginErrorMessage,
}) => ( }) => (
<LoginLayout isChangingPassword={isChangingPassword}> <LoginLayout isChangingPassword={isChangingPassword}>
{!isChangingPassword && ( {!isChangingPassword && (
<InnerBox> <InnerBox>
{loginErrorMessage && <Alert className={alertStyles} severity="error" title={loginErrorMessage} />}
{!disableLoginForm && ( {!disableLoginForm && (
<LoginForm onSubmit={login} loginHint={loginHint} passwordHint={passwordHint} isLoggingIn={isLoggingIn}> <LoginForm onSubmit={login} loginHint={loginHint} passwordHint={passwordHint} isLoggingIn={isLoggingIn}>
<HorizontalGroup justify="flex-end"> <HorizontalGroup justify="flex-end">
@ -56,6 +63,7 @@ export const LoginPage = () => {
{!disableUserSignUp && <UserSignup />} {!disableUserSignUp && <UserSignup />}
</InnerBox> </InnerBox>
)} )}
{isChangingPassword && ( {isChangingPassword && (
<InnerBox> <InnerBox>
<ChangePassword <ChangePassword

View File

@ -163,6 +163,12 @@
"success": "Bibliotheks-Panel wurde gespeichert" "success": "Bibliotheks-Panel wurde gespeichert"
} }
}, },
"login": {
"error": {
"invalid-user-or-password": "",
"unknown": ""
}
},
"nav": { "nav": {
"admin": { "admin": {
"subtitle": "Serverweite Einstellungen und Zugriff auf Ressourcen wie Organisationen, Benutzer und Lizenzen verwalten", "subtitle": "Serverweite Einstellungen und Zugriff auf Ressourcen wie Organisationen, Benutzer und Lizenzen verwalten",

View File

@ -163,6 +163,12 @@
"success": "Library panel saved" "success": "Library panel saved"
} }
}, },
"login": {
"error": {
"invalid-user-or-password": "Invalid username or password",
"unknown": "Unknown error occurred"
}
},
"nav": { "nav": {
"admin": { "admin": {
"subtitle": "Manage server-wide settings and access to resources such as organizations, users, and licenses", "subtitle": "Manage server-wide settings and access to resources such as organizations, users, and licenses",

View File

@ -163,6 +163,12 @@
"success": "Panel de librería guardado" "success": "Panel de librería guardado"
} }
}, },
"login": {
"error": {
"invalid-user-or-password": "",
"unknown": ""
}
},
"nav": { "nav": {
"admin": { "admin": {
"subtitle": "Administrar la configuración de todo el servidor y el acceso a recursos como organizaciones, usuarios y licencias", "subtitle": "Administrar la configuración de todo el servidor y el acceso a recursos como organizaciones, usuarios y licencias",

View File

@ -163,6 +163,12 @@
"success": "Panneau de bibliothèque enregistré" "success": "Panneau de bibliothèque enregistré"
} }
}, },
"login": {
"error": {
"invalid-user-or-password": "",
"unknown": ""
}
},
"nav": { "nav": {
"admin": { "admin": {
"subtitle": "Gérer les paramètres à l'échelle du serveur et l'accès aux ressources telles que les organisations, les utilisateurs et les licences", "subtitle": "Gérer les paramètres à l'échelle du serveur et l'accès aux ressources telles que les organisations, les utilisateurs et les licences",

View File

@ -163,6 +163,12 @@
"success": "Ŀįþřäřy päʼnęľ şävęđ" "success": "Ŀįþřäřy päʼnęľ şävęđ"
} }
}, },
"login": {
"error": {
"invalid-user-or-password": "Ĩʼnväľįđ ūşęřʼnämę őř päşşŵőřđ",
"unknown": "Ůʼnĸʼnőŵʼn ęřřőř őččūřřęđ"
}
},
"nav": { "nav": {
"admin": { "admin": {
"subtitle": "Mäʼnäģę şęřvęř-ŵįđę şęŧŧįʼnģş äʼnđ äččęşş ŧő řęşőūřčęş şūčĥ äş őřģäʼnįžäŧįőʼnş, ūşęřş, äʼnđ ľįčęʼnşęş", "subtitle": "Mäʼnäģę şęřvęř-ŵįđę şęŧŧįʼnģş äʼnđ äččęşş ŧő řęşőūřčęş şūčĥ äş őřģäʼnįžäŧįőʼnş, ūşęřş, äʼnđ ľįčęʼnşęş",

View File

@ -163,6 +163,12 @@
"success": "库面板已保存" "success": "库面板已保存"
} }
}, },
"login": {
"error": {
"invalid-user-or-password": "",
"unknown": ""
}
},
"nav": { "nav": {
"admin": { "admin": {
"subtitle": "管理整个服务器范围的设置,以及对组织、用户和许可证等资源的访问权限", "subtitle": "管理整个服务器范围的设置,以及对组织、用户和许可证等资源的访问权限",