Skip to content
Merged
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
1 change: 1 addition & 0 deletions client/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

.vite
node_modules
dist
dist-ssr
Expand Down
68 changes: 25 additions & 43 deletions client/src/Admin/Invites/AdminInviteForm.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { Alert, Button, Container, Fieldset, Group, Stack, Textarea, TextInput, Title } from '@mantine/core';
import { isEmail, isNotEmpty, useForm } from '@mantine/form';
import { useMutation } from '@tanstack/react-query';
import { Head } from '@unhead/react';

Expand All @@ -9,75 +9,57 @@ import Api from '../../Api';
function AdminInviteForm () {
const navigate = useNavigate();

const [invite, setInvite] = useState({
firstName: '',
lastName: '',
email: '',
message: '',
const form = useForm({
initialValues: {
firstName: '',
lastName: '',
email: '',
message: '',
},
validate: {
firstName: isNotEmpty('First name is required.'),
email: isEmail('Please enter a valid email address.'),
},
});

const onSubmitMutation = useMutation({
mutationFn: () => Api.invites.create(invite),
mutationFn: (values) => Api.invites.create(values),
onSuccess: () => navigate('/admin/invites', { flash: 'Invite sent!' }),
onError: () => window.scrollTo(0, 0),
onError: (errors) => form.setErrors(errors),
onSettled: () => window.scrollTo({ top: 0, behavior: 'smooth' }),
});

function onChange (event) {
const newInvite = { ...invite };
newInvite[event.target.name] = event.target.value;
setInvite(newInvite);
}

function onSubmit (event) {
event.preventDefault();
onSubmitMutation.mutate();
}

return (
<>
<Head>
<title>Invite a new User</title>
</Head>
<Container>
<Title mb='md'>Invite a new User</Title>
<form onSubmit={onSubmit}>
<form onSubmit={form.onSubmit(onSubmitMutation.mutateAsync)}>
<Fieldset variant='unstyled' disabled={onSubmitMutation.isPending}>
<Stack w={{ base: '100%', xs: 320 }}>
{onSubmitMutation.error && onSubmitMutation.error.message && <Alert color='red'>{onSubmitMutation.error.message}</Alert>}
{form.errors._form && <Alert color='red'>{form.errors._form}</Alert>}
<TextInput
{...form.getInputProps('firstName')}
key='firstName'
label='First name'
type='text'
id='firstName'
name='firstName'
onChange={onChange}
value={invite.firstName ?? ''}
error={onSubmitMutation.error?.errorMessagesHTMLFor?.('firstName')}
/>
<TextInput
{...form.getInputProps('lastName')}
key='lastName'
label='Last name'
type='text'
id='lastName'
name='lastName'
onChange={onChange}
value={invite.lastName ?? ''}
error={onSubmitMutation.error?.errorMessagesHTMLFor?.('lastName')}
/>
<TextInput
{...form.getInputProps('email')}
key='email'
label='Email'
type='email'
id='email'
name='email'
onChange={onChange}
value={invite.email ?? ''}
error={onSubmitMutation.error?.errorMessagesHTMLFor?.('email')}
/>
<Textarea
{...form.getInputProps('message')}
key='message'
label='Message'
id='message'
name='message'
onChange={onChange}
value={invite.message ?? ''}
error={onSubmitMutation.error?.errorMessagesHTMLFor?.('message')}
/>
<Group>
<Button type='submit'>
Expand Down
49 changes: 38 additions & 11 deletions client/src/Api.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
/* eslint-disable no-throw-literal */

import axios from 'axios';

import { StatusCodes } from 'http-status-codes';
import UnexpectedError from './UnexpectedError';
import ValidationError from './ValidationError';
import { capitalize } from 'inflection';

const instance = axios.create({
headers: {
Expand Down Expand Up @@ -47,12 +48,20 @@ function calculateLastPage (response, page) {
return newLastPage;
}

function handleValidationError (error) {
function handleError (error) {
const errors = {};
if (error.response?.status === StatusCodes.UNPROCESSABLE_ENTITY) {
throw new ValidationError(error.response.data);
for (const err of error.response.data.errors) {
errors[err.path] ||= new Set();
errors[err.path].add(err.message);
}
for (const key of Object.keys(errors)) {
errors[key] = capitalize([...errors[key]].join(', '));
}
} else {
throw new UnexpectedError();
errors._form = error.message;
}
throw errors;
}

const Api = {
Expand All @@ -68,21 +77,32 @@ const Api = {
},
auth: {
login (email, password) {
return instance.post('/api/auth/login', { email, password });
return instance.post('/api/auth/login', { email, password })
.catch((error) => {
switch (error.response?.status) {
case StatusCodes.NOT_FOUND:
case StatusCodes.UNPROCESSABLE_ENTITY:
throw { _form: 'Invalid email and/or password' };
case StatusCodes.FORBIDDEN:
throw { _form: 'Your account has been deactivated.' };
default:
throw { _form: error.message };
}
});
},
logout () {
return instance.delete('/api/auth/logout');
},
register (data) {
return instance.post('/api/auth/register', data).catch(handleValidationError);
return instance.post('/api/auth/register', data).catch(handleError);
},
},
invites: {
index (page = 1) {
return instance.get('/api/invites', { params: { page } });
},
create (data) {
return instance.post('/api/invites', data).catch(handleValidationError);
return instance.post('/api/invites', data).catch(handleError);
},
get (id) {
return instance.get(`/api/invites/${id}`);
Expand All @@ -99,13 +119,20 @@ const Api = {
},
passwords: {
reset (email) {
return instance.post('/api/passwords', { email });
return instance.post('/api/passwords', { email }).catch((error) => {
switch (error.response?.status) {
case StatusCodes.NOT_FOUND:
throw { email: 'Email not found.' };
default:
throw { _form: error.message };
}
});
},
get (token) {
return instance.get(`/api/passwords/${token}`);
},
update (token, password) {
return instance.patch(`/api/passwords/${token}`, { password });
return instance.patch(`/api/passwords/${token}`, { password }).catch(handleError);
},
},
users: {
Expand All @@ -119,7 +146,7 @@ const Api = {
return instance.get(`/api/users/${id}`);
},
update (id, data) {
return instance.patch(`/api/users/${id}`, data);
return instance.patch(`/api/users/${id}`, data).catch(handleError);
},
},
};
Expand Down
31 changes: 21 additions & 10 deletions client/src/Components/PhotoInput.jsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,31 @@
import { useEffect } from 'react';
import { Box, CloseButton, Image, Input, Loader, Text } from '@mantine/core';
import { useUncontrolled } from '@mantine/hooks';
import classNames from 'classnames';

import DropzoneUploader from './DropzoneUploader';
import classes from './PhotoInput.module.css';

function PhotoInput ({ children, description, error, id, label, name, onChange, value, valueUrl }) {
function onRemoved () {
if (onChange) {
onChange({ target: { name, value: '' } });
function PhotoInput ({ children, description, error, id, label, name, onChange, defaultValue, value, valueUrl }) {
const [_value, handleChange] = useUncontrolled({
value,
defaultValue,
finalValue: '',
onChange,
});

useEffect(() => {
if (!_value) {
handleChange(defaultValue);
}
}, [defaultValue]);

function onRemoved () {
handleChange('');
}

function onUploaded (status) {
if (onChange) {
onChange({ target: { name, value: status.filename } });
}
handleChange(status.filename);
}

return (
Expand All @@ -24,7 +35,7 @@ function PhotoInput ({ children, description, error, id, label, name, onChange,
<DropzoneUploader
id={id}
multiple={false}
disabled={!!value && value !== ''}
disabled={!!_value && _value !== ''}
onRemoved={onRemoved}
onUploaded={onUploaded}
>
Expand All @@ -42,14 +53,14 @@ function PhotoInput ({ children, description, error, id, label, name, onChange,
<Loader className={classes.spinner} />
</Box>
));
} else if (statuses.length === 0 && value) {
} else if (statuses.length === 0 && _value) {
return (
<Box className={classes.preview}>
<Image src={valueUrl} alt='' />
<CloseButton className={classes.remove} onClick={onRemoved} />
</Box>
);
} else if (statuses.length === 0 && !value) {
} else if (statuses.length === 0 && !_value) {
return children || <Text className='clickable' inherit={false} fz='sm' my='sm'>Drag-and-drop a photo file here, or click here to browse and select a file.</Text>;
}
}}
Expand Down
18 changes: 2 additions & 16 deletions client/src/Invites/Invite.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router';
import { Box, Container, Stack, Title } from '@mantine/core';
import { useMutation, useQuery } from '@tanstack/react-query';
Expand All @@ -22,28 +21,15 @@ function Invite () {
},
});

const [user, setUser] = useState({
firstName: '',
lastName: '',
email: '',
password: '',
});

const onSubmitMutation = useMutation({
mutationFn: () => Api.auth.register({ ...user, inviteId }),
mutationFn: (values) => Api.auth.register({ ...values, inviteId }),
onSuccess: (response) => {
setAuthUser(response.data);
navigate('/account', { state: { flash: 'Your account has been created!' } });
},
onError: () => window.scrollTo(0, 0),
});

function onChange (event) {
const newUser = { ...user };
newUser[event.target.name] = event.target.value;
setUser(newUser);
}

return (
<>
<Head>
Expand All @@ -55,7 +41,7 @@ function Invite () {
{invite?.acceptedAt && <Box>This invite has already been accepted.</Box>}
{invite?.revokedAt && <Box>This invite is no longer available.</Box>}
{invite && invite.acceptedAt === null && invite.revokedAt === null && (
<RegistrationForm onSubmitMutation={onSubmitMutation} onChange={onChange} user={user} />
<RegistrationForm onSubmitMutation={onSubmitMutation} />
)}
</Stack>
</Container>
Expand Down
Loading