Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#17] [#23] As a user, when I am on the Login Page I can request a new password reset to my email #40

Open
wants to merge 6 commits into
base: feature/19-frontend-display-profile
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cypress/integration/Authentication/login.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
describe('User Authentication', () => {
describe('Login', () => {
context('upon navigation to /login', () => {
it('displays login page', () => {
cy.visit('/login');
Expand Down
33 changes: 33 additions & 0 deletions cypress/integration/Authentication/resetPassword.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
describe('Reset password', () => {
context('upon navigation to /login', () => {
it('clicking forgot password link navigates to reset password page', () => {
cy.visit('/login');

cy.get('a').contains('Forgot?').click();

cy.location().should((location) => {
expect(location.pathname).to.eq('/reset-password');
});
});
});

context('upon navigation to /reset-password', () => {
it('clicking on send recovery email displays sent message given valid email format', () => {
cy.visit('/reset-password');

cy.findByTestId('reset-password-header').should('be.visible');

cy.get('button[type="submit"]').click();

cy.get('.errors').should('be.visible');

cy.get('input[name=email]').type('[email protected]');
cy.get('button[type="submit"]').click();

cy.get('.errors').should('not.be.visible');

cy.findByText("We've emailed you instructions to reset your password").should('be.visible');
cy.findByText('Check your email').should('be.visible');
});
});
});
6 changes: 6 additions & 0 deletions public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,11 @@
"invalid-email": "Email has invalid format",
"invalid-password": "Password should be at least {{passwordMinLength}}",
"generic-server-error": "There was a problem receiving a response from the server"
},
"reset-password": {
"recovery-email": "Send Recovery Email",
"header": "Enter your email to receive instructions for resetting your password.",
"success-message": "We've emailed you instructions to reset your password",
"check-email": "Check your email"
}
}
16 changes: 16 additions & 0 deletions src/adapters/authAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,20 @@ describe('AuthAdapter', () => {
expect(scope.isDone()).toBe(true);
});
});

describe('resetPassword', () => {
test('The resetPassword endpoint is called', async () => {
const scope = nock(`${process.env.REACT_APP_API_ENDPOINT}`)
.defaultReplyHeaders({
'access-control-allow-origin': '*',
'access-control-allow-credentials': 'true',
})
.post('/passwords')
.reply(200);

expect(scope.isDone()).toBe(false);
expect(await AuthAdapter.resetPassword(mockLoginCredentials.email));
expect(scope.isDone()).toBe(true);
});
});
});
9 changes: 9 additions & 0 deletions src/adapters/authAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ class AuthAdapter extends BaseAdapter {
return this.prototype.postRequest('oauth/revoke', { data: requestParams });
}

static resetPassword(email: string) {
const requestParams = {
...OauthParams,
user: { email: email },
};

return this.prototype.postRequest('passwords', { data: requestParams });
}

static getUser() {
return this.prototype.getRequest('me', {});
}
Expand Down
Binary file added src/assets/images/bell-notification.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@ import { RouteObject } from 'react-router-dom';
import PrivateRoute from 'components/PrivateRoute';
import HomeScreen from 'screens/Home';
import LoginScreen from 'screens/Login';
import ResetPasswordScreen from 'screens/ResetPassword';

const routes: RouteObject[] = [
{
path: '/login',
element: <LoginScreen />,
},
{
path: '/reset-password',
element: <ResetPasswordScreen />,
},
{
element: <PrivateRoute />,
children: [
Expand Down
6 changes: 3 additions & 3 deletions src/screens/Login/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useNavigate, Link } from 'react-router-dom';

import { AxiosError } from 'axios';

Expand Down Expand Up @@ -116,9 +116,9 @@ function LoginScreen() {
/>

{/* TODO: Change to React Router Link when implement #17 */}
<a href="." className="absolute left-60 top-5 my-8 text-white opacity-50">
<Link to="/reset-password" className="absolute left-60 top-5 my-8 text-white opacity-50">
{t('login.forgot-password')}
</a>
</Link>
</div>
<Button text={t('login.sign-in')} className="h-14 w-80" type="submit" disabled={formSubmitted} />
</form>
Expand Down
91 changes: 91 additions & 0 deletions src/screens/ResetPassword/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { BrowserRouter } from 'react-router-dom';

import '@testing-library/jest-dom';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import nock from 'nock';

import ResetPasswordScreen from '.';
import AuthAdapter from '../../adapters/authAdapter';

/* eslint-disable camelcase */
const commonPasswordResetParams = {
user: {
email: '[email protected]',
},
client_id: process.env.REACT_APP_API_CLIENT_ID,
client_secret: process.env.REACT_APP_API_CLIENT_SECRET,
};

const commonResetPasswordResponse = {
meta: {
message:
'If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes.',
},
};

/* eslint-enable camelcase */

describe('ResetPasswordScreen', () => {
beforeEach(() => {
jest.clearAllMocks();
});

afterEach(() => {
jest.restoreAllMocks();
});

afterAll(() => {
nock.cleanAll();
nock.restore();
});

test('given an empty email in the form, displays error', async () => {
const mockResetPassword = jest.spyOn(AuthAdapter, 'resetPassword');

render(<ResetPasswordScreen />, { wrapper: BrowserRouter });

const submitButton = screen.getByRole('button', { name: 'reset-password.recovery-email' });

await userEvent.click(submitButton);

expect(mockResetPassword).not.toBeCalled();

expect(screen.getByText('login.invalid-email')).toBeInTheDocument();
});

test('given a valid email, displays instructions sent message', async () => {
nock(`${process.env.REACT_APP_API_ENDPOINT}`)
.defaultReplyHeaders({
'access-control-allow-origin': '*',
'access-control-allow-credentials': 'true',
'access-control-allow-headers': 'Authorization',
})
.post('/passwords', {
...commonPasswordResetParams,
})
.reply(200, {
...commonResetPasswordResponse,
});

render(<ResetPasswordScreen />, { wrapper: BrowserRouter });

const emailField = screen.getByLabelText('login.email');
const submitButton = screen.getByRole('button', { name: 'reset-password.recovery-email' });

await userEvent.type(emailField, '[email protected]');

expect(submitButton).not.toHaveAttribute('disabled');

await userEvent.click(submitButton);

expect(submitButton).toHaveAttribute('disabled');

await Promise.resolve();

await waitFor(() => {
expect(screen.getByText('reset-password.check-email')).toBeInTheDocument();
});
expect(screen.getByText('reset-password.success-message')).toBeInTheDocument();
});
});
99 changes: 99 additions & 0 deletions src/screens/ResetPassword/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';

import { AxiosError } from 'axios';

import AuthAdapter from 'adapters/authAdapter';
import bellNotification from 'assets/images/bell-notification.png';
import Button from 'components/Button';
import Input from 'components/Input';
import { isEmailValid } from 'helpers/validators';

function ResetPasswordScreen() {
const { t } = useTranslation('translation');

const [email, setEmail] = useState('');
const [errors, setErrors] = useState<string[]>([]);
const [showSuccessMessage, setShowSuccessMessage] = useState(false);
const [formSubmitted, setFormSubmitted] = useState(false);

const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value);
};

const performPasswordReset = async () => {
try {
await AuthAdapter.resetPassword(email);
} catch (error) {
let errorMessage = t('login.generic-server-error');

if (error instanceof Error) {
errorMessage = (error as AxiosError).response?.data?.errors[0]?.detail || error.cause || errorMessage;
}
setErrors([errorMessage]);
} finally {
setFormSubmitted(false);
setShowSuccessMessage(true);
}
};

const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();

const formErrors = [];

if (!isEmailValid(email)) {
formErrors.push(t('login.invalid-email'));
}

setErrors(formErrors);

if (formErrors.length === 0) {
performPasswordReset();
setFormSubmitted(true);
} else {
setShowSuccessMessage(false);
}
};

return (
<>
<p data-test-id="reset-password-header" className="mb-8 w-80 text-center text-white opacity-50">
{t('reset-password.header')}
</p>

<div className="errors">
{errors.length > 0 &&
errors.map((error) => {
return (
<p className="text-center text-red-700" key={error.toString()}>
{error}
</p>
);
})}
</div>

{showSuccessMessage && (
<div className="my-3 w-80 rounded-xl bg-stone-800/60 p-5">
<img className="inline-block" src={bellNotification} alt="notification-bell"></img>
<p className="inline-block p-3 font-semibold text-white">{t('reset-password.check-email')}</p>
<p className="right-10 inline-block p-3 text-white opacity-60">{t('reset-password.success-message')}</p>
</div>
)}

<form onSubmit={handleSubmit}>
<Input
name="email"
label={t('login.email')}
type="text"
value={email}
className="my-3 block h-14 w-80"
onInputChange={handleEmailChange}
/>
<Button text={t('reset-password.recovery-email')} className="h-14 w-80" type="submit" disabled={formSubmitted} />
</form>
</>
);
}

export default ResetPasswordScreen;