ForgottenPassword: Adds better error handling (#34174)

* ForgottenPassword: Adds better error handling

* Chore: updates after PR comments
This commit is contained in:
Hugo Häggmark 2021-05-17 09:15:41 +02:00 committed by GitHub
parent c9145541b0
commit e246ec0d5b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 223 additions and 44 deletions

View File

@ -0,0 +1,118 @@
import React from 'react';
import { asyncScheduler, scheduled, throwError } from 'rxjs';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ForgottenPassword, SendResetEmailDTO } from './ForgottenPassword';
import { backendSrv } from '../../services/backend_srv';
import { toAsyncOfResult } from '../../../features/query/state/DashboardQueryRunner/testHelpers';
import { createFetchResponse } from '../../../../test/helpers/createFetchResponse';
jest.mock('@grafana/runtime', () => ({
...((jest.requireActual('@grafana/runtime') as unknown) as object),
getBackendSrv: () => backendSrv,
}));
function getTestContext() {
jest.clearAllMocks();
const fetchMock = jest.spyOn(backendSrv, 'fetch');
const { rerender } = render(<ForgottenPassword />);
return { fetchMock, rerender };
}
function getInputField() {
return screen.getByRole('textbox', { name: /user enter your information to get a reset link sent to you/i });
}
function getSubmitButton() {
return screen.getByRole('button', { name: /send reset email/i });
}
async function enterUserNameAndSubmitForm(fetchMock: jest.SpyInstance) {
await userEvent.type(getInputField(), 'JaneDoe');
userEvent.click(getSubmitButton());
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
expect(fetchMock).toHaveBeenCalledWith({
url: '/api/user/password/send-reset-email',
method: 'POST',
data: { userOrEmail: 'JaneDoe' },
showSuccessAlert: false,
showErrorAlert: false,
});
}
describe('ForgottenPassword', () => {
describe('when mounted', () => {
it('then it should show input field', () => {
getTestContext();
expect(getInputField()).toBeInTheDocument();
});
it('then it should show send button', () => {
getTestContext();
expect(getSubmitButton()).toBeInTheDocument();
});
it('then it should show back to login link', () => {
getTestContext();
expect(screen.getByRole('link', { name: /back to login/i })).toBeInTheDocument();
});
});
describe('when user submits form', () => {
describe('and response is ok', () => {
it('then it should show success message', async () => {
const { fetchMock } = getTestContext();
fetchMock.mockImplementation(() =>
toAsyncOfResult(
createFetchResponse<SendResetEmailDTO>({ message: 'Success' })
)
);
await enterUserNameAndSubmitForm(fetchMock);
expect(
screen.getByText(
/an email with a reset link has been sent to the email address\. you should receive it shortly\./i
)
).toBeInTheDocument();
});
});
describe('and response is ok but contains an error', () => {
it('then it should show alert', async () => {
const { fetchMock } = getTestContext();
fetchMock.mockImplementation(() =>
toAsyncOfResult(
createFetchResponse<SendResetEmailDTO>({ message: 'Success', error: 'Something went wrong' })
)
);
await enterUserNameAndSubmitForm(fetchMock);
expect(screen.getByLabelText(/alert error/i)).toBeInTheDocument();
expect(screen.getByText(/couldn't send reset link to the email address/i)).toBeInTheDocument();
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
});
});
describe('and response is not ok', () => {
it('then it should show alert', async () => {
const { fetchMock } = getTestContext();
fetchMock.mockImplementation(() => scheduled(throwError('Server error'), asyncScheduler));
await enterUserNameAndSubmitForm(fetchMock);
expect(screen.getByLabelText(/alert error/i)).toBeInTheDocument();
expect(screen.getByText(/couldn't send reset link to the email address/i)).toBeInTheDocument();
expect(screen.getByText(/server error/i)).toBeInTheDocument();
});
});
});
});

View File

@ -1,32 +1,68 @@
import React, { FC, useState } from 'react';
import { Form, Field, Input, Button, Legend, Container, useStyles, HorizontalGroup, LinkButton } from '@grafana/ui';
import { getBackendSrv } from '@grafana/runtime';
import React, { useState } from 'react';
import { Unsubscribable } from 'rxjs';
import { css } from '@emotion/css';
import { GrafanaTheme } from '@grafana/data';
import config from 'app/core/config';
import {
Alert,
Button,
Container,
FadeTransition,
Field,
Form,
HorizontalGroup,
Input,
Legend,
LinkButton,
useStyles2,
} from '@grafana/ui';
import { getBackendSrv, toDataQueryError } from '@grafana/runtime';
import { GrafanaTheme2 } from '@grafana/data';
import { getConfig } from 'app/core/config';
interface EmailDTO {
userOrEmail: string;
}
const paragraphStyles = (theme: GrafanaTheme) => css`
color: ${theme.colors.formDescription};
font-size: ${theme.typography.size.sm};
font-weight: ${theme.typography.weight.regular};
margin-top: ${theme.spacing.sm};
display: block;
`;
export interface SendResetEmailDTO {
message: string;
error?: string;
}
export const ForgottenPassword: FC = () => {
export function ForgottenPassword(): JSX.Element {
const [emailSent, setEmailSent] = useState(false);
const styles = useStyles(paragraphStyles);
const loginHref = `${config.appSubUrl}/login`;
const [error, setError] = useState<string | null>(null);
const styles = useStyles2(getStyles);
const loginHref = `${getConfig().appSubUrl}/login`;
const sendEmail = async (formModel: EmailDTO) => {
const res = await getBackendSrv().post('/api/user/password/send-reset-email', formModel);
if (res) {
setEmailSent(true);
}
const sendEmail = (formModel: EmailDTO) => {
setError(null);
// using fetch here so we can disable showSuccessAlert, showErrorAlert and handle those
const subscription: Unsubscribable = getBackendSrv()
.fetch<SendResetEmailDTO>({
url: '/api/user/password/send-reset-email',
method: 'POST',
data: formModel,
showSuccessAlert: false,
showErrorAlert: false,
})
.subscribe({
next: (response) => {
const { error, message } = response.data; // the backend api can respond with 200 Ok but still have an error set
if (error) {
setError(error);
} else if (message) {
setEmailSent(true);
}
subscription.unsubscribe(); // unsubscribe as soon as a value is received
},
error: (err) => {
const dataError = toDataQueryError(err);
setError(dataError.message ?? 'Unknown error');
subscription.unsubscribe(); // unsubscribe as soon as an error is received
},
});
};
if (emailSent) {
@ -40,29 +76,54 @@ export const ForgottenPassword: FC = () => {
</div>
);
}
return (
<Form onSubmit={sendEmail}>
{({ register, errors }) => (
<>
<Legend>Reset password</Legend>
<Field
label="User"
description="Enter your information to get a reset link sent to you"
invalid={!!errors.userOrEmail}
error={errors?.userOrEmail?.message}
>
<Input placeholder="Email or username" {...register('userOrEmail', { required: true })} />
</Field>
<HorizontalGroup>
<Button>Send reset email</Button>
<LinkButton fill="text" href={loginHref}>
Back to login
</LinkButton>
</HorizontalGroup>
<p className={styles}>Did you forget your username or email? Contact your Grafana administrator.</p>
</>
)}
</Form>
return (
<>
<Form onSubmit={sendEmail}>
{({ register, errors }) => (
<>
<Legend>Reset password</Legend>
<Field
label="User"
description="Enter your information to get a reset link sent to you"
invalid={!!errors.userOrEmail}
error={errors?.userOrEmail?.message}
>
<Input
id={'userOrEmail'}
placeholder="Email or username"
{...register('userOrEmail', { required: true })}
/>
</Field>
<HorizontalGroup>
<Button>Send reset email</Button>
<LinkButton fill="text" href={loginHref}>
Back to login
</LinkButton>
</HorizontalGroup>
<p className={styles}>Did you forget your username or email? Contact your Grafana administrator.</p>
</>
)}
</Form>
<FadeTransition duration={150} visible={Boolean(error)}>
<Alert title="Couldn't send reset link to the email address" severity={'error'}>
<span>
<strong>Reason:&nbsp;</strong>
{error}
</span>
</Alert>
</FadeTransition>
</>
);
};
}
function getStyles(theme: GrafanaTheme2) {
return css`
color: ${theme.colors.text.secondary};
font-size: ${theme.typography.size.sm};
font-weight: ${theme.typography.fontWeightRegular};
margin-top: ${theme.spacing(1)};
display: block;
`;
}