mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Login: Improve accessibility of Login form (#78652)
* Chore: Fix a11y debt in Login form * fix tests * token styles * more styles * pa11y * fix pa11y
This commit is contained in:
parent
7dbbdc16a3
commit
eea35b9eb7
@ -1139,9 +1139,6 @@ exports[`better eslint`] = {
|
|||||||
[0, 0, 0, "Styles should be written using objects.", "0"],
|
[0, 0, 0, "Styles should be written using objects.", "0"],
|
||||||
[0, 0, 0, "Styles should be written using objects.", "1"]
|
[0, 0, 0, "Styles should be written using objects.", "1"]
|
||||||
],
|
],
|
||||||
"public/app/core/components/ForgottenPassword/ChangePassword.tsx:5381": [
|
|
||||||
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"]
|
|
||||||
],
|
|
||||||
"public/app/core/components/ForgottenPassword/ForgottenPassword.tsx:5381": [
|
"public/app/core/components/ForgottenPassword/ForgottenPassword.tsx:5381": [
|
||||||
[0, 0, 0, "Styles should be written using objects.", "0"]
|
[0, 0, 0, "Styles should be written using objects.", "0"]
|
||||||
],
|
],
|
||||||
@ -1178,38 +1175,6 @@ exports[`better eslint`] = {
|
|||||||
[0, 0, 0, "Styles should be written using objects.", "3"],
|
[0, 0, 0, "Styles should be written using objects.", "3"],
|
||||||
[0, 0, 0, "Styles should be written using objects.", "4"]
|
[0, 0, 0, "Styles should be written using objects.", "4"]
|
||||||
],
|
],
|
||||||
"public/app/core/components/Login/LoginForm.tsx:5381": [
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "0"],
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "1"],
|
|
||||||
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "2"],
|
|
||||||
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "3"]
|
|
||||||
],
|
|
||||||
"public/app/core/components/Login/LoginLayout.tsx:5381": [
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "0"],
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "1"],
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "2"],
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "3"],
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "4"],
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "5"],
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "6"],
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "7"],
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "8"],
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "9"],
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "10"]
|
|
||||||
],
|
|
||||||
"public/app/core/components/Login/LoginPage.tsx:5381": [
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "0"]
|
|
||||||
],
|
|
||||||
"public/app/core/components/Login/LoginServiceButtons.tsx:5381": [
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "0"],
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "1"],
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "2"],
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "3"],
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "4"]
|
|
||||||
],
|
|
||||||
"public/app/core/components/Login/UserSignup.tsx:5381": [
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "0"]
|
|
||||||
],
|
|
||||||
"public/app/core/components/NestedFolderPicker/Trigger.tsx:5381": [
|
"public/app/core/components/NestedFolderPicker/Trigger.tsx:5381": [
|
||||||
[0, 0, 0, "Styles should be written using objects.", "0"]
|
[0, 0, 0, "Styles should be written using objects.", "0"]
|
||||||
],
|
],
|
||||||
@ -1331,9 +1296,6 @@ exports[`better eslint`] = {
|
|||||||
[0, 0, 0, "Styles should be written using objects.", "0"],
|
[0, 0, 0, "Styles should be written using objects.", "0"],
|
||||||
[0, 0, 0, "Styles should be written using objects.", "1"]
|
[0, 0, 0, "Styles should be written using objects.", "1"]
|
||||||
],
|
],
|
||||||
"public/app/core/components/PasswordField/PasswordField.tsx:5381": [
|
|
||||||
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"]
|
|
||||||
],
|
|
||||||
"public/app/core/components/QueryOperationRow/OperationRowHelp.tsx:5381": [
|
"public/app/core/components/QueryOperationRow/OperationRowHelp.tsx:5381": [
|
||||||
[0, 0, 0, "Styles should be written using objects.", "0"]
|
[0, 0, 0, "Styles should be written using objects.", "0"]
|
||||||
],
|
],
|
||||||
|
@ -72,8 +72,8 @@ var config = {
|
|||||||
"wait for element input[name='user'] to be added",
|
"wait for element input[name='user'] to be added",
|
||||||
"set field input[name='user'] to admin",
|
"set field input[name='user'] to admin",
|
||||||
"set field input[name='password'] to admin",
|
"set field input[name='password'] to admin",
|
||||||
"click element button[aria-label='Login button']",
|
"click element button[data-testid='data-testid Login button']",
|
||||||
"wait for element [aria-label='Skip change password button'] to be visible",
|
"wait for element button[data-testid='data-testid Skip change password button'] to be visible",
|
||||||
],
|
],
|
||||||
threshold: 15,
|
threshold: 15,
|
||||||
rootElement: '.main-view',
|
rootElement: '.main-view',
|
||||||
|
@ -61,8 +61,8 @@ var config = {
|
|||||||
"wait for element input[name='user'] to be added",
|
"wait for element input[name='user'] to be added",
|
||||||
"set field input[name='user'] to admin",
|
"set field input[name='user'] to admin",
|
||||||
"set field input[name='password'] to admin",
|
"set field input[name='password'] to admin",
|
||||||
"click element button[aria-label='Login button']",
|
"click element button[data-testid='data-testid Login button']",
|
||||||
"wait for element [aria-label='Skip change password button'] to be visible",
|
"wait for element button[data-testid='data-testid Skip change password button'] to be visible",
|
||||||
],
|
],
|
||||||
wait: 500,
|
wait: 500,
|
||||||
rootElement: '.main-view',
|
rootElement: '.main-view',
|
||||||
|
@ -8,10 +8,10 @@ import { Components } from './components';
|
|||||||
export const Pages = {
|
export const Pages = {
|
||||||
Login: {
|
Login: {
|
||||||
url: '/login',
|
url: '/login',
|
||||||
username: 'Username input field',
|
username: 'data-testid Username input field',
|
||||||
password: 'Password input field',
|
password: 'data-testid Password input field',
|
||||||
submit: 'Login button',
|
submit: 'data-testid Login button',
|
||||||
skip: 'Skip change password button',
|
skip: 'data-testid Skip change password button',
|
||||||
},
|
},
|
||||||
Home: {
|
Home: {
|
||||||
url: '/',
|
url: '/',
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import React, { SyntheticEvent } from 'react';
|
import React, { SyntheticEvent } from 'react';
|
||||||
|
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { Tooltip, Form, Field, VerticalGroup, Button, Alert } from '@grafana/ui';
|
import { Tooltip, Form, Field, VerticalGroup, Button, Alert, useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { submitButton } from '../Login/LoginForm';
|
import { getStyles } from '../Login/LoginForm';
|
||||||
import { PasswordField } from '../PasswordField/PasswordField';
|
import { PasswordField } from '../PasswordField/PasswordField';
|
||||||
interface Props {
|
interface Props {
|
||||||
onSubmit: (pw: string) => void;
|
onSubmit: (pw: string) => void;
|
||||||
@ -17,6 +17,7 @@ interface PasswordDTO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ChangePassword = ({ onSubmit, onSkip, showDefaultPasswordWarning }: Props) => {
|
export const ChangePassword = ({ onSubmit, onSkip, showDefaultPasswordWarning }: Props) => {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
const submit = (passwords: PasswordDTO) => {
|
const submit = (passwords: PasswordDTO) => {
|
||||||
onSubmit(passwords.newPassword);
|
onSubmit(passwords.newPassword);
|
||||||
};
|
};
|
||||||
@ -29,24 +30,24 @@ export const ChangePassword = ({ onSubmit, onSkip, showDefaultPasswordWarning }:
|
|||||||
)}
|
)}
|
||||||
<Field label="New password" invalid={!!errors.newPassword} error={errors?.newPassword?.message}>
|
<Field label="New password" invalid={!!errors.newPassword} error={errors?.newPassword?.message}>
|
||||||
<PasswordField
|
<PasswordField
|
||||||
|
{...register('newPassword', { required: 'New Password is required' })}
|
||||||
id="new-password"
|
id="new-password"
|
||||||
autoFocus
|
autoFocus
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
{...register('newPassword', { required: 'New Password is required' })}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Confirm new password" invalid={!!errors.confirmNew} error={errors?.confirmNew?.message}>
|
<Field label="Confirm new password" invalid={!!errors.confirmNew} error={errors?.confirmNew?.message}>
|
||||||
<PasswordField
|
<PasswordField
|
||||||
id="confirm-new-password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
{...register('confirmNew', {
|
{...register('confirmNew', {
|
||||||
required: 'Confirmed Password is required',
|
required: 'Confirmed Password is required',
|
||||||
validate: (v: string) => v === getValues().newPassword || 'Passwords must match!',
|
validate: (v: string) => v === getValues().newPassword || 'Passwords must match!',
|
||||||
})}
|
})}
|
||||||
|
id="confirm-new-password"
|
||||||
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<VerticalGroup>
|
<VerticalGroup>
|
||||||
<Button type="submit" className={submitButton}>
|
<Button type="submit" className={styles.submitButton}>
|
||||||
Submit
|
Submit
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@ -55,7 +56,7 @@ export const ChangePassword = ({ onSubmit, onSkip, showDefaultPasswordWarning }:
|
|||||||
content="If you skip you will be prompted to change password next time you log in."
|
content="If you skip you will be prompted to change password next time you log in."
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
>
|
>
|
||||||
<Button fill="text" onClick={onSkip} type="button" aria-label={selectors.pages.Login.skip}>
|
<Button fill="text" onClick={onSkip} type="button" data-testid={selectors.pages.Login.skip}>
|
||||||
Skip
|
Skip
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import React, { ReactElement } from 'react';
|
import React, { ReactElement, useId } from 'react';
|
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { Button, Form, Input, Field } from '@grafana/ui';
|
import { Button, Form, Input, Field, useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { PasswordField } from '../PasswordField/PasswordField';
|
import { PasswordField } from '../PasswordField/PasswordField';
|
||||||
|
|
||||||
@ -16,43 +17,38 @@ interface Props {
|
|||||||
loginHint: string;
|
loginHint: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const wrapperStyles = css`
|
|
||||||
width: 100%;
|
|
||||||
padding-bottom: 16px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const submitButton = css`
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const LoginForm = ({ children, onSubmit, isLoggingIn, passwordHint, loginHint }: Props) => {
|
export const LoginForm = ({ children, onSubmit, isLoggingIn, passwordHint, loginHint }: Props) => {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
const usernameId = useId();
|
||||||
|
const passwordId = useId();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={wrapperStyles}>
|
<div className={styles.wrapper}>
|
||||||
<Form onSubmit={onSubmit} validateOn="onChange">
|
<Form onSubmit={onSubmit} validateOn="onChange">
|
||||||
{({ register, errors }) => (
|
{({ register, errors }) => (
|
||||||
<>
|
<>
|
||||||
<Field label="Email or username" invalid={!!errors.user} error={errors.user?.message}>
|
<Field label="Email or username" invalid={!!errors.user} error={errors.user?.message}>
|
||||||
<Input
|
<Input
|
||||||
{...register('user', { required: 'Email or username is required' })}
|
{...register('user', { required: 'Email or username is required' })}
|
||||||
|
id={usernameId}
|
||||||
autoFocus
|
autoFocus
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
placeholder={loginHint}
|
placeholder={loginHint}
|
||||||
aria-label={selectors.pages.Login.username}
|
data-testid={selectors.pages.Login.username}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Password" invalid={!!errors.password} error={errors.password?.message}>
|
<Field label="Password" invalid={!!errors.password} error={errors.password?.message}>
|
||||||
<PasswordField
|
<PasswordField
|
||||||
id="current-password"
|
|
||||||
autoComplete="current-password"
|
|
||||||
passwordHint={passwordHint}
|
|
||||||
{...register('password', { required: 'Password is required' })}
|
{...register('password', { required: 'Password is required' })}
|
||||||
|
id={passwordId}
|
||||||
|
autoComplete="current-password"
|
||||||
|
placeholder={passwordHint}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
aria-label={selectors.pages.Login.submit}
|
data-testid={selectors.pages.Login.submit}
|
||||||
className={submitButton}
|
className={styles.submitButton}
|
||||||
disabled={isLoggingIn}
|
disabled={isLoggingIn}
|
||||||
>
|
>
|
||||||
{isLoggingIn ? 'Logging in...' : 'Log in'}
|
{isLoggingIn ? 'Logging in...' : 'Log in'}
|
||||||
@ -64,3 +60,17 @@ export const LoginForm = ({ children, onSubmit, isLoggingIn, passwordHint, login
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getStyles = (theme: GrafanaTheme2) => {
|
||||||
|
return {
|
||||||
|
wrapper: css({
|
||||||
|
width: '100%',
|
||||||
|
paddingBottom: theme.spacing(2),
|
||||||
|
}),
|
||||||
|
|
||||||
|
submitButton: css({
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '100%',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
@ -2,7 +2,7 @@ import { cx, css, keyframes } from '@emotion/css';
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { useStyles2, styleMixins } from '@grafana/ui';
|
import { useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { Branding } from '../Branding/Branding';
|
import { Branding } from '../Branding/Branding';
|
||||||
import { BrandingSettings } from '../Branding/types';
|
import { BrandingSettings } from '../Branding/types';
|
||||||
@ -92,90 +92,90 @@ export const getLoginStyles = (theme: GrafanaTheme2) => {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
}),
|
}),
|
||||||
loginAnim: css`
|
loginAnim: css({
|
||||||
&:before {
|
['&:before']: {
|
||||||
opacity: 1;
|
opacity: 1,
|
||||||
}
|
},
|
||||||
|
|
||||||
.login-content-box {
|
['.login-content-box']: {
|
||||||
opacity: 1;
|
opacity: 1,
|
||||||
}
|
},
|
||||||
`,
|
}),
|
||||||
submitButton: css`
|
submitButton: css({
|
||||||
justify-content: center;
|
justifyContent: 'center',
|
||||||
width: 100%;
|
width: '100%',
|
||||||
`,
|
}),
|
||||||
loginLogo: css`
|
loginLogo: css({
|
||||||
width: 100%;
|
width: '100%',
|
||||||
max-width: 60px;
|
maxWidth: 60,
|
||||||
margin-bottom: 15px;
|
marginBottom: theme.spacing(2),
|
||||||
|
|
||||||
@media ${styleMixins.mediaUp(theme.v1.breakpoints.sm)} {
|
[theme.breakpoints.up('sm')]: {
|
||||||
max-width: 100px;
|
maxWidth: 100,
|
||||||
}
|
},
|
||||||
`,
|
}),
|
||||||
loginLogoWrapper: css`
|
loginLogoWrapper: css({
|
||||||
display: flex;
|
display: 'flex',
|
||||||
align-items: center;
|
alignItems: 'center',
|
||||||
justify-content: center;
|
justifyContent: 'center',
|
||||||
flex-direction: column;
|
flexDirection: 'column',
|
||||||
padding: ${theme.spacing(3)};
|
padding: theme.spacing(3),
|
||||||
`,
|
}),
|
||||||
titleWrapper: css`
|
titleWrapper: css({
|
||||||
text-align: center;
|
textAlign: 'center',
|
||||||
`,
|
}),
|
||||||
mainTitle: css`
|
mainTitle: css({
|
||||||
font-size: 22px;
|
fontSize: 22,
|
||||||
|
|
||||||
@media ${styleMixins.mediaUp(theme.v1.breakpoints.sm)} {
|
[theme.breakpoints.up('sm')]: {
|
||||||
font-size: 32px;
|
fontSize: 32,
|
||||||
}
|
},
|
||||||
`,
|
}),
|
||||||
subTitle: css`
|
subTitle: css({
|
||||||
font-size: ${theme.typography.size.md};
|
fontSize: theme.typography.size.md,
|
||||||
color: ${theme.colors.text.secondary};
|
color: theme.colors.text.secondary,
|
||||||
`,
|
}),
|
||||||
loginContent: css`
|
loginContent: css({
|
||||||
max-width: 478px;
|
maxWidth: 478,
|
||||||
width: calc(100% - 2rem);
|
width: `calc(100% - 2rem)`,
|
||||||
display: flex;
|
display: 'flex',
|
||||||
align-items: stretch;
|
alignItems: 'stretch',
|
||||||
flex-direction: column;
|
flexDirection: 'column',
|
||||||
position: relative;
|
position: 'relative',
|
||||||
justify-content: flex-start;
|
justifyContent: 'flex-start',
|
||||||
z-index: 1;
|
zIndex: 1,
|
||||||
min-height: 320px;
|
minHeight: 320,
|
||||||
border-radius: ${theme.shape.borderRadius(4)};
|
borderRadius: theme.shape.borderRadius(4),
|
||||||
padding: ${theme.spacing(2, 0)};
|
padding: theme.spacing(2, 0),
|
||||||
opacity: 0;
|
opacity: 0,
|
||||||
transition: opacity 0.5s ease-in-out;
|
transition: 'opacity 0.5s ease-in-out',
|
||||||
|
|
||||||
@media ${styleMixins.mediaUp(theme.v1.breakpoints.sm)} {
|
[theme.breakpoints.up('sm')]: {
|
||||||
min-height: 320px;
|
minHeight: theme.spacing(40),
|
||||||
justify-content: center;
|
justifyContent: 'center',
|
||||||
}
|
},
|
||||||
`,
|
}),
|
||||||
loginOuterBox: css`
|
loginOuterBox: css({
|
||||||
display: flex;
|
display: 'flex',
|
||||||
overflow-y: hidden;
|
overflowU: 'hidden',
|
||||||
align-items: center;
|
alignItems: 'center',
|
||||||
justify-content: center;
|
justifyContent: 'center',
|
||||||
`,
|
}),
|
||||||
loginInnerBox: css`
|
loginInnerBox: css({
|
||||||
padding: ${theme.spacing(0, 2, 2, 2)};
|
padding: theme.spacing(0, 2, 2, 2),
|
||||||
|
|
||||||
display: flex;
|
display: 'flex',
|
||||||
flex-direction: column;
|
flexDirection: 'column',
|
||||||
align-items: center;
|
alignItems: 'center',
|
||||||
justify-content: center;
|
justifyContent: 'center',
|
||||||
flex-grow: 1;
|
flexGrow: 1,
|
||||||
max-width: 415px;
|
maxWidth: 415,
|
||||||
width: 100%;
|
width: '100%',
|
||||||
transform: translate(0px, 0px);
|
transform: 'translate(0px, 0px)',
|
||||||
transition: 0.25s ease;
|
transition: '0.25s ease',
|
||||||
`,
|
}),
|
||||||
enterAnimation: css`
|
enterAnimation: css({
|
||||||
animation: ${flyInAnimation} ease-out 0.2s;
|
animation: `${flyInAnimation} ease-out 0.2s`,
|
||||||
`,
|
}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -39,9 +39,9 @@ describe('Login Page', () => {
|
|||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
|
|
||||||
expect(screen.getByRole('heading', { name: 'Welcome to Grafana' })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: 'Welcome to Grafana' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('textbox', { name: 'Username input field' })).toBeInTheDocument();
|
expect(screen.getByRole('textbox', { name: 'Email or username' })).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('Password input field')).toBeInTheDocument();
|
expect(screen.getByLabelText('Password')).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: 'Login button' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument();
|
||||||
|
|
||||||
expect(screen.getByRole('link', { name: 'Forgot your password?' })).toBeInTheDocument();
|
expect(screen.getByRole('link', { name: 'Forgot your password?' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('link', { name: 'Forgot your password?' })).toHaveAttribute(
|
expect(screen.getByRole('link', { name: 'Forgot your password?' })).toHaveAttribute(
|
||||||
@ -56,20 +56,20 @@ describe('Login Page', () => {
|
|||||||
it('should pass validation checks for username field', async () => {
|
it('should pass validation checks for username field', async () => {
|
||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Login button' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
|
||||||
expect(await screen.findByText('Email or username is required')).toBeInTheDocument();
|
expect(await screen.findByText('Email or username is required')).toBeInTheDocument();
|
||||||
|
|
||||||
await userEvent.type(screen.getByRole('textbox', { name: 'Username input field' }), 'admin');
|
await userEvent.type(screen.getByRole('textbox', { name: 'Email or username' }), '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 />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Login button' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
|
||||||
expect(await screen.findByText('Password is required')).toBeInTheDocument();
|
expect(await screen.findByText('Password is required')).toBeInTheDocument();
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText('Password input field'), 'admin');
|
await userEvent.type(screen.getByLabelText('Password'), 'admin');
|
||||||
await waitFor(() => expect(screen.queryByText('Password is required')).not.toBeInTheDocument());
|
await waitFor(() => expect(screen.queryByText('Password is required')).not.toBeInTheDocument());
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -82,9 +82,9 @@ describe('Login Page', () => {
|
|||||||
postMock.mockResolvedValueOnce({ message: 'Logged in' });
|
postMock.mockResolvedValueOnce({ message: 'Logged in' });
|
||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText('Username input field'), 'admin');
|
await userEvent.type(screen.getByLabelText('Email or username'), 'admin');
|
||||||
await userEvent.type(screen.getByLabelText('Password input field'), 'test');
|
await userEvent.type(screen.getByLabelText('Password'), 'test');
|
||||||
fireEvent.click(screen.getByLabelText('Login button'));
|
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(postMock).toHaveBeenCalledWith('/login', { password: 'test', user: 'admin' }, { showErrorAlert: false })
|
expect(postMock).toHaveBeenCalledWith('/login', { password: 'test', user: 'admin' }, { showErrorAlert: false })
|
||||||
@ -128,9 +128,9 @@ describe('Login Page', () => {
|
|||||||
|
|
||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText('Username input field'), 'admin');
|
await userEvent.type(screen.getByLabelText('Email or username'), 'admin');
|
||||||
await userEvent.type(screen.getByLabelText('Password input field'), 'test');
|
await userEvent.type(screen.getByLabelText('Password'), 'test');
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Login button' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Log in' }));
|
||||||
|
|
||||||
const alert = await screen.findByRole('alert', { name: 'Login failed' });
|
const alert = await screen.findByRole('alert', { name: 'Login failed' });
|
||||||
expect(alert).toBeInTheDocument();
|
expect(alert).toBeInTheDocument();
|
||||||
@ -150,9 +150,9 @@ describe('Login Page', () => {
|
|||||||
|
|
||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText('Username input field'), 'admin');
|
await userEvent.type(screen.getByLabelText('Email or username'), 'admin');
|
||||||
await userEvent.type(screen.getByLabelText('Password input field'), 'test');
|
await userEvent.type(screen.getByLabelText('Password'), 'test');
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Login button' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Log in' }));
|
||||||
|
|
||||||
const alert = await screen.findByRole('alert', { name: 'Login failed' });
|
const alert = await screen.findByRole('alert', { name: 'Login failed' });
|
||||||
expect(alert).toBeInTheDocument();
|
expect(alert).toBeInTheDocument();
|
||||||
|
@ -3,7 +3,8 @@ import { css } from '@emotion/css';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import { Alert, HorizontalGroup, LinkButton } from '@grafana/ui';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { Alert, HorizontalGroup, LinkButton, useStyles2 } 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';
|
||||||
import { t } from 'app/core/internationalization';
|
import { t } from 'app/core/internationalization';
|
||||||
@ -16,17 +17,10 @@ import { LoginLayout, InnerBox } from './LoginLayout';
|
|||||||
import { LoginServiceButtons } from './LoginServiceButtons';
|
import { LoginServiceButtons } from './LoginServiceButtons';
|
||||||
import { UserSignup } from './UserSignup';
|
import { UserSignup } from './UserSignup';
|
||||||
|
|
||||||
const forgottenPasswordStyles = css`
|
|
||||||
padding: 0;
|
|
||||||
margin-top: 4px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const alertStyles = css({
|
|
||||||
width: '100%',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const LoginPage = () => {
|
export const LoginPage = () => {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
document.title = Branding.AppTitle;
|
document.title = Branding.AppTitle;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LoginCtrl>
|
<LoginCtrl>
|
||||||
{({
|
{({
|
||||||
@ -46,7 +40,7 @@ export const LoginPage = () => {
|
|||||||
{!isChangingPassword && (
|
{!isChangingPassword && (
|
||||||
<InnerBox>
|
<InnerBox>
|
||||||
{loginErrorMessage && (
|
{loginErrorMessage && (
|
||||||
<Alert className={alertStyles} severity="error" title={t('login.error.title', 'Login failed')}>
|
<Alert className={styles.alert} severity="error" title={t('login.error.title', 'Login failed')}>
|
||||||
{loginErrorMessage}
|
{loginErrorMessage}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
@ -55,7 +49,7 @@ export const LoginPage = () => {
|
|||||||
<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">
|
||||||
<LinkButton
|
<LinkButton
|
||||||
className={forgottenPasswordStyles}
|
className={styles.forgottenPassword}
|
||||||
fill="text"
|
fill="text"
|
||||||
href={`${config.appSubUrl}/user/password/send-reset-email`}
|
href={`${config.appSubUrl}/user/password/send-reset-email`}
|
||||||
>
|
>
|
||||||
@ -83,3 +77,16 @@ export const LoginPage = () => {
|
|||||||
</LoginCtrl>
|
</LoginCtrl>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => {
|
||||||
|
return {
|
||||||
|
forgottenPassword: css({
|
||||||
|
padding: 0,
|
||||||
|
marginTop: theme.spacing(0.5),
|
||||||
|
}),
|
||||||
|
|
||||||
|
alert: css({
|
||||||
|
width: '100%',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
@ -77,30 +77,30 @@ const loginServices: () => LoginServices = () => {
|
|||||||
|
|
||||||
const getServiceStyles = (theme: GrafanaTheme2) => {
|
const getServiceStyles = (theme: GrafanaTheme2) => {
|
||||||
return {
|
return {
|
||||||
button: css`
|
button: css({
|
||||||
color: #d8d9da;
|
color: '#d8d9da',
|
||||||
position: relative;
|
position: 'relative',
|
||||||
`,
|
}),
|
||||||
buttonIcon: css`
|
buttonIcon: css({
|
||||||
position: absolute;
|
position: 'absolute',
|
||||||
left: ${theme.spacing(1)};
|
left: theme.spacing(1),
|
||||||
top: 50%;
|
top: '50%',
|
||||||
transform: translateY(-50%);
|
transform: 'translateY(-50%)',
|
||||||
`,
|
}),
|
||||||
divider: {
|
divider: {
|
||||||
base: css`
|
base: css({
|
||||||
color: ${theme.colors.text};
|
color: theme.colors.text.primary,
|
||||||
display: flex;
|
display: 'flex',
|
||||||
margin-bottom: ${theme.spacing(1)};
|
marginBottom: theme.spacing(1),
|
||||||
justify-content: space-between;
|
justifyContent: 'space-between',
|
||||||
text-align: center;
|
textAlign: 'center',
|
||||||
width: 100%;
|
width: '100%',
|
||||||
`,
|
}),
|
||||||
line: css`
|
line: css({
|
||||||
width: 100px;
|
width: 100,
|
||||||
height: 10px;
|
height: 10,
|
||||||
border-bottom: 1px solid ${theme.colors.text};
|
borderBottom: `1px solid ${theme.colors.text}`,
|
||||||
`,
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@ -128,15 +128,15 @@ const LoginDivider = () => {
|
|||||||
function getButtonStyleFor(service: LoginService, styles: ReturnType<typeof getServiceStyles>, theme: GrafanaTheme2) {
|
function getButtonStyleFor(service: LoginService, styles: ReturnType<typeof getServiceStyles>, theme: GrafanaTheme2) {
|
||||||
return cx(
|
return cx(
|
||||||
styles.button,
|
styles.button,
|
||||||
css`
|
css({
|
||||||
background-color: ${service.bgColor};
|
backgroundColor: service.bgColor,
|
||||||
color: ${theme.colors.getContrastText(service.bgColor)};
|
color: theme.colors.getContrastText(service.bgColor),
|
||||||
|
|
||||||
&:hover {
|
['&:hover']: {
|
||||||
background-color: ${theme.colors.emphasize(service.bgColor, 0.15)};
|
backgroundColor: theme.colors.emphasize(service.bgColor, 0.15),
|
||||||
box-shadow: ${theme.shadows.z1};
|
boxShadow: theme.shadows.z1,
|
||||||
}
|
},
|
||||||
`
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -12,10 +12,10 @@ export const UserSignup = () => {
|
|||||||
<VerticalGroup>
|
<VerticalGroup>
|
||||||
<div className={paddingTop}>New to Grafana?</div>
|
<div className={paddingTop}>New to Grafana?</div>
|
||||||
<LinkButton
|
<LinkButton
|
||||||
className={css`
|
className={css({
|
||||||
width: 100%;
|
width: '100%',
|
||||||
justify-content: center;
|
justifyContent: 'center',
|
||||||
`}
|
})}
|
||||||
href={href}
|
href={href}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
fill="outline"
|
fill="outline"
|
||||||
|
@ -1,23 +1,31 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Field } from '@grafana/ui';
|
||||||
|
|
||||||
import { PasswordField } from './PasswordField';
|
import { PasswordField } from './PasswordField';
|
||||||
|
|
||||||
describe('PasswordField', () => {
|
describe('PasswordField', () => {
|
||||||
const props = {
|
|
||||||
id: 'password',
|
|
||||||
placeholder: 'enter password',
|
|
||||||
'data-testid': 'password-field',
|
|
||||||
};
|
|
||||||
it('should render correctly', () => {
|
it('should render correctly', () => {
|
||||||
render(<PasswordField {...props} />);
|
render(
|
||||||
expect(screen.getByTestId('password-field')).toBeInTheDocument();
|
<Field label="Password">
|
||||||
|
<PasswordField id="password" />
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('Password')).toBeInTheDocument();
|
||||||
expect(screen.getByRole('switch', { name: 'Show password' })).toBeInTheDocument();
|
expect(screen.getByRole('switch', { name: 'Show password' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should able to show password value if clicked on password-reveal icon', () => {
|
it('should able to show password value if clicked on password-reveal icon', () => {
|
||||||
render(<PasswordField {...props} />);
|
render(
|
||||||
expect(screen.getByTestId('password-field')).toHaveProperty('type', 'password');
|
<Field label="Password">
|
||||||
|
<PasswordField id="password" />
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('Password')).toHaveProperty('type', 'password');
|
||||||
fireEvent.click(screen.getByRole('switch', { name: 'Show password' }));
|
fireEvent.click(screen.getByRole('switch', { name: 'Show password' }));
|
||||||
expect(screen.getByTestId('password-field')).toHaveProperty('type', 'text');
|
expect(screen.getByLabelText('Password')).toHaveProperty('type', 'text');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -2,32 +2,23 @@ import React, { useState } from 'react';
|
|||||||
|
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { Input, IconButton } from '@grafana/ui';
|
import { Input, IconButton } from '@grafana/ui';
|
||||||
|
import { Props as InputProps } from '@grafana/ui/src/components/Input/Input';
|
||||||
|
|
||||||
export interface Props {
|
interface Props extends Omit<InputProps, 'type'> {}
|
||||||
autoFocus?: boolean;
|
|
||||||
autoComplete?: string;
|
|
||||||
id?: string;
|
|
||||||
passwordHint?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PasswordField = React.forwardRef<HTMLInputElement, Props>(
|
export const PasswordField = React.forwardRef<HTMLInputElement, Props>((props, ref) => {
|
||||||
({ autoComplete, autoFocus, id, passwordHint, ...props }, ref) => {
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
id={id}
|
|
||||||
autoFocus={autoFocus}
|
|
||||||
autoComplete={autoComplete}
|
|
||||||
{...props}
|
{...props}
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
placeholder={passwordHint}
|
data-testid={selectors.pages.Login.password}
|
||||||
aria-label={selectors.pages.Login.password}
|
|
||||||
ref={ref}
|
ref={ref}
|
||||||
suffix={
|
suffix={
|
||||||
<IconButton
|
<IconButton
|
||||||
name={showPassword ? 'eye-slash' : 'eye'}
|
name={showPassword ? 'eye-slash' : 'eye'}
|
||||||
aria-controls={id}
|
aria-controls={props.id}
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={showPassword}
|
aria-checked={showPassword}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -38,7 +29,6 @@ export const PasswordField = React.forwardRef<HTMLInputElement, Props>(
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
PasswordField.displayName = 'PasswordField';
|
PasswordField.displayName = 'PasswordField';
|
||||||
|
Loading…
Reference in New Issue
Block a user