Skip to content

Commit

Permalink
Cleanup and password reset in account view
Browse files Browse the repository at this point in the history
  • Loading branch information
EtienneK committed May 27, 2020
1 parent 2efbaaa commit b3359ae
Show file tree
Hide file tree
Showing 10 changed files with 98 additions and 54 deletions.
2 changes: 1 addition & 1 deletion components/LoadingButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ interface Props {

export default function LoadingButton({ children, loading, loadingText = 'Please wait...' }: Props): JSX.Element {
return (
<Button variant="primary" size="lg" block className="mt-4" type="submit">
<Button variant="primary" size="lg" block type="submit">
{loading && (<Spinner animation="border" size="sm" />)}
{loading && ` ${loadingText}`}
{!loading && (children)}
Expand Down
10 changes: 0 additions & 10 deletions models/Account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,6 @@ export interface AccountInterface extends Document {
* Helper method for validating user's password.
*/
comparePassword: (candidatePassword: string) => Promise<boolean>;
/**
* Helper method for getting user's gravatar.
*/
gravatar: (size?: number) => URL;
}

const schemaDefinition: SchemaDefinition = {
Expand All @@ -38,12 +34,6 @@ schema.methods
return bcrypt.compare(candidatePassword, this.password);
};

schema.methods.gravatar = function gravatar(size = 200): URL {
if (!this.email) return new URL(`https://gravatar.com/avatar/?s=${size}&d=retro`);
const md5 = crypto.createHash('md5').update(this.email).digest('hex');
return new URL(`https://gravatar.com/avatar/${md5}?s=${size}&d=retro`);
};

const AccountModel = (conn: Connection): Model<AccountInterface> => (
conn.models[modelName] ? conn.models[modelName] : conn.model(
modelName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ import { useForm } from 'react-hook-form';

import isEmail from 'validator/lib/isEmail';

import LoadingButton from '../../../components/LoadingButton';
import useIsAuthenticated from '../../../hooks/useIsAuthenticated';
import AccountContainer from '../../../components/AccountContainer';
import LoadingButton from '../../components/LoadingButton';
import useIsAuthenticated from '../../hooks/useIsAuthenticated';
import AccountContainer from '../../components/AccountContainer';

export default function ForgotPassword(): JSX.Element {
const { publicRuntimeConfig: { appName } } = getConfig();
Expand All @@ -26,13 +26,15 @@ export default function ForgotPassword(): JSX.Element {
const router = useRouter();

const { data: isAuthenticatedData } = useIsAuthenticated();
useEffect(() => { if (isAuthenticatedData && isAuthenticatedData.isAuthenticated) router.replace('/'); }, [isAuthenticatedData]);
useEffect(() => {
if (isAuthenticatedData && isAuthenticatedData.isAuthenticated) router.replace('/account');
}, [isAuthenticatedData]);

const onSubmit = async (data: any): Promise<void> => {
if (loading) return;
setLoading(true);
try {
const res = await fetch('/api/account/forgot-password', {
const res = await fetch('/api/account/reset-password', {
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json',
Expand Down
83 changes: 77 additions & 6 deletions pages/account/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import React, { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';

import Accordion from 'react-bootstrap/Accordion';
import Alert from 'react-bootstrap/Alert';
import Button from 'react-bootstrap/Button';
import Card from 'react-bootstrap/Card';
import Form from 'react-bootstrap/Form';
import Image from 'react-bootstrap/Image';
import Spinner from 'react-bootstrap/Spinner';

import { BsCheckBox } from 'react-icons/bs';

import AccountContainer from '../../components/AccountContainer';
import LoadingButton from '../../components/LoadingButton';
import EmailInput from '../../components/EmailInput';
Expand All @@ -17,6 +19,9 @@ export default function ForgotPasswordReset(): JSX.Element {
const [gettingMe, setGettingMe] = useState(false);
const [submittingProfile, setSubmittingProfile] = useState(false);

const [submittingPasswordReset, setSubmittingPasswordReset] = useState(false);
const [passwordResetSubmitted, setPasswordResetSubmitted] = useState(false);

useEffect(() => {
if (gettingMe) return;
setGettingMe(true);
Expand All @@ -29,9 +34,42 @@ export default function ForgotPasswordReset(): JSX.Element {
register, handleSubmit, watch, errors, setError,
} = useForm();

const {
handleSubmit: handlePasswordResetSubmit,
} = useForm();

const passwordResetSubmit = async (): Promise<void> => {
if (submittingPasswordReset) return;
setSubmittingPasswordReset(true);
try {
const res = await fetch('/api/account/reset-password', {
body: JSON.stringify({ email: me.email }),
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
});

switch (res.status) {
case 200:
setSubmittingPasswordReset(false);
setPasswordResetSubmitted(true);
break;
default:
throw new Error('An unknown error has occured. Please try again later.');
}
} catch (err) {
setError('unknown', 'unknown');
setSubmittingPasswordReset(false);
}
};

if (!me) {
return (
<AccountContainer className="text-center">
<div className="text-center">
<h1 className="h3 mb-4">Account</h1>
</div>
<Spinner animation="border" />
</AccountContainer>
);
Expand All @@ -41,6 +79,9 @@ export default function ForgotPasswordReset(): JSX.Element {
<AccountContainer>
<div className="text-center">
<h1 className="h3 mb-4">Account</h1>
{errors.unknown && (
<Alert variant="danger">{errors.unknown.message}</Alert>
)}
</div>
<Accordion defaultActiveKey="0">
<Card>
Expand All @@ -51,9 +92,6 @@ export default function ForgotPasswordReset(): JSX.Element {
</Card.Header>
<Accordion.Collapse eventKey="0">
<Card.Body>
<div className="text-center">
<Image className="mb-4" src={me.avatar.med} width={72} height={72} />
</div>
<Form noValidate>
<EmailInput
disabled={submittingProfile}
Expand All @@ -75,12 +113,45 @@ export default function ForgotPasswordReset(): JSX.Element {
</Accordion.Toggle>
</Card.Header>
<Accordion.Collapse eventKey="1">
<Card.Body>TODO</Card.Body>
<Card.Body>

{passwordResetSubmitted
? (
<>
<Alert variant="success">
<BsCheckBox />
&nbsp;We have sent you an email with password reset instructions.
</Alert>
<p>
Please check your spam folder if you did not receive an email.
</p>
<p>
Otherwise, you will have to wait 30 minutes for your previous password
reset request to expire before being able to request another.
</p>
<Button
block
size="lg"
onClick={(): void => setPasswordResetSubmitted(false)}
>
OK
</Button>
</>
)
: (
<Form
noValidate
onSubmit={handlePasswordResetSubmit(passwordResetSubmit)}
>
<LoadingButton loading={submittingPasswordReset}>Reset password</LoadingButton>
</Form>
)}
</Card.Body>
</Accordion.Collapse>
</Card>
<Card>
<Card.Header>
<Accordion.Toggle as={Button} variant="link" eventKey="2">
<Accordion.Toggle className="text-danger" as={Button} variant="link" eventKey="2">
Delete account
</Accordion.Toggle>
</Card.Header>
Expand Down
2 changes: 1 addition & 1 deletion pages/account/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export default function Login(): JSX.Element {
/>
</Form.Group>

<p className="float-right">
<p className="float-right small">
<Link href="/account/forgot-password"><a>Forgot password?</a></Link>
</p>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import Spinner from 'react-bootstrap/Spinner';
import { useForm } from 'react-hook-form';

import LoadingButton from '../../../components/LoadingButton';
import useIsAuthenticated from '../../../hooks/useIsAuthenticated';
import PasswordChange from '../../../components/PasswordChange';
import AccountContainer from '../../../components/AccountContainer';
import useIsAuthenticated from '../../../hooks/useIsAuthenticated';

enum CurrentState {
CheckingToken,
Expand All @@ -25,14 +25,15 @@ enum CurrentState {
export default function ForgotPasswordReset(): JSX.Element {
const router = useRouter();
const { token } = router.query;
const { data: isAuthenticatedData } = useIsAuthenticated();

const [currentState, setCurrentState] = useState<CurrentState>(CurrentState.CheckingToken);

useEffect((): void => {
if (!token) return;
if (currentState !== CurrentState.CheckingToken) return;

fetch(`/api/account/forgot-password/${token}`)
fetch(`/api/account/reset-password/${token}`)
.then((res) => {
switch (res.status) {
case 200:
Expand All @@ -51,14 +52,11 @@ export default function ForgotPasswordReset(): JSX.Element {
register, handleSubmit, watch, errors, setError,
} = useForm();

const { data: isAuthenticatedData } = useIsAuthenticated();
useEffect(() => { if (isAuthenticatedData && isAuthenticatedData.isAuthenticated) router.replace('/'); }, [isAuthenticatedData]);

const onSubmit = async (data: any): Promise<void> => {
if (currentState === CurrentState.ResetPasswordFormSubmitted) return;
setCurrentState(CurrentState.ResetPasswordFormSubmitted);
try {
const res = await fetch(`/api/account/forgot-password/${token}`, {
const res = await fetch(`/api/account/reset-password/${token}`, {
body: JSON.stringify({ ...data, token }),
headers: {
'Content-Type': 'application/json',
Expand Down Expand Up @@ -92,6 +90,7 @@ export default function ForgotPasswordReset(): JSX.Element {
if (currentState === CurrentState.CheckingToken) {
return (
<AccountContainer className="text-center">
<h1 className="h3">Password reset</h1>
<Spinner animation="border" />
</AccountContainer>
);
Expand All @@ -102,7 +101,7 @@ export default function ForgotPasswordReset(): JSX.Element {
return (
<AccountContainer>
<div className="text-center">
<h1 className="h3">Forgot password reset</h1>
<h1 className="h3">Password reset</h1>
{errors.unknown && (
<Alert variant="danger">An unknown error has occured. Please try again later.</Alert>
)}
Expand Down Expand Up @@ -132,8 +131,10 @@ export default function ForgotPasswordReset(): JSX.Element {
return (
<AccountContainer>
<Alert variant="success">
<p>Your password has successfully been reset.</p>
<p className="text-center"><Link href="/account/login"><a>Proceed to Login</a></Link></p>
<p>Your password has successfully been changed.</p>
{isAuthenticatedData?.isAuthenticated
? (<p className="text-center"><Link href="/account"><a>Proceed to account</a></Link></p>)
: (<p className="text-center"><Link href="/account/login"><a>Proceed to Login</a></Link></p>)}
</Alert>
</AccountContainer>
);
Expand All @@ -143,7 +144,7 @@ export default function ForgotPasswordReset(): JSX.Element {
return (
<AccountContainer>
<Alert variant="warning">
<p>Your forgot password reset request has expired.</p>
<p>Your forgot password reset request is invalid or has expired.</p>
<p className="text-center"><Link href="/account/forgot-password"><a>Send another reset request</a></Link></p>
</Alert>
</AccountContainer>
Expand Down
3 changes: 0 additions & 3 deletions pages/api/account/me.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,6 @@ handler.get(
res.status(200).json({
id,
email,
avatar: {
med: account.gravatar(72),
},
});
},
);
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ handler.post(
});
await forgotPasswordToken.save();

const url = `${process.env.NEXT_PUBLIC_BASE_URL}/account/forgot-password/${token}`;
const url = `${process.env.NEXT_PUBLIC_BASE_URL}/account/reset-password/${token}`;

await sendEmail(email, `Forgotten password reset for your ${appName} account`, {
type: 'text/html',
Expand Down
17 changes: 0 additions & 17 deletions test/unit/models/Account.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,4 @@ describe('Account Model', () => {
// Assert
expect(matched).toBeFalsy();
});

it('should generate gravatar without email and size', () => {
const account = new Account();
expect(account.gravatar().toString()).toContain('gravatar.com');
});

it('should generate gravatar with size', () => {
const account = new Account();
const size = 300;
expect(account.gravatar(size).toString()).toContain(`s=${size}`);
});

it('should generate gravatar with email', () => {
const account = new Account({ email: '[email protected]' });
const md5 = '2e0d5407ce8609047b8255c50405d7b1';
expect(account.gravatar().toString()).toContain(md5);
});
});

1 comment on commit b3359ae

@vercel
Copy link

@vercel vercel bot commented on b3359ae May 27, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.