Skip to content

Commit

Permalink
feat: add password changing form
Browse files Browse the repository at this point in the history
  • Loading branch information
paulschwoerer committed Oct 19, 2021
1 parent fe22ef5 commit d49f985
Show file tree
Hide file tree
Showing 12 changed files with 341 additions and 45 deletions.
27 changes: 27 additions & 0 deletions web/src/components/Alert/Alert.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
@import "scss/vars";
@import "scss/mixins";

.root {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0.5rem 1rem;
margin-bottom: 2rem;
color: #fff;
border: 1px solid;
border-radius: 3px;

&.error {
background: rgba($colorDanger, 0.8);
border-color: $colorDanger;
}

&.success {
background: rgba($colorAccent, 0.8);
border-color: $colorAccent;
}

@include shadow(low);
}
22 changes: 22 additions & 0 deletions web/src/components/Alert/Alert.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { Alert } from './Alert';

describe('Alert', () => {
it('renders the given message', async () => {
render(<Alert message="this is a message" />);

await waitFor(() =>
expect(screen.getByText('this is a message')).toBeInTheDocument(),
);
});

it('calls close handler when close button is clicked', async () => {
const handleClose = jest.fn();

render(<Alert message="this is a message" onClose={handleClose} />);
fireEvent.click(screen.getByRole('button'));

await waitFor(() => expect(handleClose).toHaveBeenCalled());
});
});
39 changes: 39 additions & 0 deletions web/src/components/Alert/Alert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import classNames from 'classnames';
import { CloseIcon } from 'components/icons';
import IconButton from 'components/icons/IconButton/IconButton';
import React, { ReactElement } from 'react';
import styles from './Alert.module.scss';

type Props = {
message: string;
onClose?: () => void;
};

function BaseAlert({
message,
className,
onClose,
}: Props & { className?: string }): ReactElement | null {
if (!message.length) {
return null;
}

return (
<div className={classNames(styles.root, className)}>
{message}
<IconButton onClick={onClose} icon={<CloseIcon />} />
</div>
);
}

export function ErrorAlert(props: Props): ReactElement {
return <BaseAlert {...props} className={styles.error} />;
}

export function SuccessAlert(props: Props): ReactElement {
return <BaseAlert {...props} className={styles.success} />;
}

export function Alert(props: Props): ReactElement {
return <BaseAlert {...props} />;
}
2 changes: 1 addition & 1 deletion web/src/components/MainContent/MainContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import ArtistDetails from 'views/ArtistDetails';
import Landing from 'views/Landing';
import Queue from 'views/Queue';
import Search from 'views/Search';
import UserSettings from 'views/UserSettings';
import UserSettings from 'views/UserSettings/UserSettings';
import styles from './MainContent.module.scss';

type Props = {
Expand Down
17 changes: 0 additions & 17 deletions web/src/components/form/FormCard/FormCard.module.scss
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
@import "scss/vars";
@import "scss/mixins";

.root {
Expand All @@ -16,22 +15,6 @@

@include shadow(medium);

.error {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0.5rem 1rem;
margin-bottom: 2rem;
background: rgba($colorDanger, 0.8);
color: #fff;
border: 1px solid $colorDanger;
border-radius: 3px;

@include shadow(low);
}

.actions {
display: flex;
flex-direction: column;
Expand Down
12 changes: 3 additions & 9 deletions web/src/components/form/FormCard/FormCard.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { CloseIcon } from 'components/icons';
import IconButton from 'components/icons/IconButton/IconButton';
import { ErrorAlert } from 'components/Alert/Alert';
import React, { PropsWithChildren, ReactElement, ReactNode } from 'react';
import Logo from '../../layout/Logo/Logo';
import styles from './FormCard.module.scss';

type Props = {
actions?: ReactNode;
error?: string;
error: string;
onCloseError?: () => void;
onSubmit?: () => void;
};
Expand All @@ -22,12 +21,7 @@ function FormCard({
<div className={styles.root}>
<Logo />
<form className={styles.form} onSubmit={onSubmit}>
{!!error && (
<div className={styles.error}>
{error}
<IconButton onClick={onCloseError} icon={<CloseIcon />} />
</div>
)}
<ErrorAlert message={error} onClose={onCloseError} />
{children}
<div className={styles.actions}>{actions}</div>
</form>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import PasswordChanging from './PasswordChanging';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { ApiError, ChangePasswordRequestDto } from 'leafplayer-common';

const server = setupServer();

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe('PasswordChanging', () => {
it('successfully changes password', async () => {
server.use(
rest.post<ChangePasswordRequestDto>(
'/api/auth/password',
(req, res, ctx) => {
return res(ctx.status(204));
},
),
);

render(<PasswordChanging />);

await fillAndSubmitForm();

await waitFor(() =>
expect(
screen.getByText(/you changed your password successfully/i),
).toBeInTheDocument(),
);
});

it('displays potential errors', async () => {
server.use(
rest.post('/api/auth/password', (req, res, ctx) => {
return res.once(
ctx.status(401),
ctx.json<ApiError>({
error: 'Unauthorized',
message: 'invalid password',
statusCode: 401,
}),
);
}),
);

render(<PasswordChanging />);

await fillAndSubmitForm();

await waitFor(() =>
expect(screen.getByText(/invalid password/i)).toBeInTheDocument(),
);
});
});

async function fillAndSubmitForm() {
const currentPasswordInput = await screen.findByLabelText('Current Password');
const newPasswordPasswordInput = await screen.findByLabelText('New Password');
const repeatPasswordInput = await screen.findByLabelText(
'Repeat New Password',
);
const submitButton = await screen.findByRole('button');

fireEvent.input(currentPasswordInput, {
target: { value: 'supersecret' },
});
fireEvent.input(newPasswordPasswordInput, {
target: { value: 'timeforachange' },
});
fireEvent.input(repeatPasswordInput, {
target: { value: 'timeforachange' },
});
fireEvent.click(submitButton);
}
115 changes: 115 additions & 0 deletions web/src/components/settings/PasswordChanging/PasswordChanging.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { ErrorAlert, SuccessAlert } from 'components/Alert/Alert';
import { ButtonPrimary } from 'components/form/Button/Button';
import Input from 'components/form/Input/Input';
import { useFormik } from 'formik';
import { ChangePasswordRequestDto } from 'leafplayer-common';
import { isApiError, makeApiPostRequest } from 'modules/api';
import React, { ReactElement, useState } from 'react';

function PasswordChanging(): ReactElement {
const [error, setError] = useState('');
const [isSuccess, setIsSuccess] = useState(false);

const formik = useFormik({
initialValues: {
currentPassword: '',
newPassword: '',
passwordRepeat: '',
},

validate: values => {
const errors: { [key: string]: string } = {};

if (!values.currentPassword) {
errors.inviteCode = 'Required';
}

if (!values.newPassword) {
errors.username = 'Required';
}

if (!values.passwordRepeat) {
errors.password = 'Required';
}

if (
values.newPassword.length > 0 &&
values.passwordRepeat !== values.newPassword
) {
errors.passwordRepeat = 'Passwords do not match';
}

return errors;
},

onSubmit: async ({ currentPassword, newPassword }) => {
setError('');
setIsSuccess(false);

const result = await makeApiPostRequest<never, ChangePasswordRequestDto>(
'auth/password',
{
currentPassword,
newPassword,
},
);

if (isApiError(result)) {
setError(result.message);
} else {
setIsSuccess(true);
}
},
});

return (
<>
<ErrorAlert message={error} onClose={() => setError('')} />

{isSuccess && (
<SuccessAlert
message="You changed your password successfully"
onClose={() => setIsSuccess(false)}
/>
)}

<form onSubmit={formik.handleSubmit}>
<Input
type="password"
label="Current Password"
name="currentPassword"
value={formik.values.currentPassword}
onBlur={formik.handleBlur}
onInput={formik.handleChange}
error={
formik.touched.currentPassword && formik.errors.currentPassword
}
/>

<Input
type="password"
label="New Password"
name="newPassword"
value={formik.values.newPassword}
onBlur={formik.handleBlur}
onInput={formik.handleChange}
error={formik.touched.newPassword && formik.errors.newPassword}
/>

<Input
type="password"
label="Repeat New Password"
name="passwordRepeat"
value={formik.values.passwordRepeat}
onBlur={formik.handleBlur}
onInput={formik.handleChange}
error={formik.touched.passwordRepeat && formik.errors.passwordRepeat}
/>

<ButtonPrimary type="submit">Change Password</ButtonPrimary>
</form>
</>
);
}

export default PasswordChanging;
18 changes: 0 additions & 18 deletions web/src/views/UserSettings.tsx

This file was deleted.

7 changes: 7 additions & 0 deletions web/src/views/UserSettings/UserSettings.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.container {
@media screen and (min-width: 1200px) {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4rem;
}
}
Loading

0 comments on commit d49f985

Please sign in to comment.