Login: Improve accessibility of Login form (#78652)

* Chore: Fix a11y debt in Login form

* fix tests

* token styles

* more styles

* pa11y

* fix pa11y
pull/78734/head
Josh Hunt 2 years ago committed by GitHub
parent 7dbbdc16a3
commit eea35b9eb7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 38
      .betterer.results
  2. 4
      .pa11yci-pr.conf.js
  3. 4
      .pa11yci.conf.js
  4. 8
      packages/grafana-e2e-selectors/src/selectors/pages.ts
  5. 15
      public/app/core/components/ForgottenPassword/ChangePassword.tsx
  6. 48
      public/app/core/components/Login/LoginForm.tsx
  7. 162
      public/app/core/components/Login/LoginLayout.tsx
  8. 32
      public/app/core/components/Login/LoginPage.test.tsx
  9. 31
      public/app/core/components/Login/LoginPage.tsx
  10. 62
      public/app/core/components/Login/LoginServiceButtons.tsx
  11. 8
      public/app/core/components/Login/UserSignup.tsx
  12. 28
      public/app/core/components/PasswordField/PasswordField.test.tsx
  13. 60
      public/app/core/components/PasswordField/PasswordField.tsx

@ -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,43 +2,33 @@ 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} {...props}
autoFocus={autoFocus} type={showPassword ? 'text' : 'password'}
autoComplete={autoComplete} data-testid={selectors.pages.Login.password}
{...props} ref={ref}
type={showPassword ? 'text' : 'password'} suffix={
placeholder={passwordHint} <IconButton
aria-label={selectors.pages.Login.password} name={showPassword ? 'eye-slash' : 'eye'}
ref={ref} aria-controls={props.id}
suffix={ role="switch"
<IconButton aria-checked={showPassword}
name={showPassword ? 'eye-slash' : 'eye'} onClick={() => {
aria-controls={id} setShowPassword(!showPassword);
role="switch" }}
aria-checked={showPassword} tooltip={showPassword ? 'Hide password' : 'Show password'}
onClick={() => { />
setShowPassword(!showPassword); }
}} />
tooltip={showPassword ? 'Hide password' : 'Show password'} );
/> });
}
/>
);
}
);
PasswordField.displayName = 'PasswordField'; PasswordField.displayName = 'PasswordField';

Loading…
Cancel
Save