From 4579dfd5b22c340e799e0a5483e2aa5098a51740 Mon Sep 17 00:00:00 2001 From: Ben Demers Date: Fri, 20 Jan 2023 14:17:37 -0500 Subject: [PATCH 01/18] pop-up --- .../src/AdminDashboard/DeleteUserButton.tsx | 107 ++++++------ client/src/App.tsx | 158 +++++++++--------- client/src/components/AlertPopup.tsx | 26 +++ client/src/index.tsx | 29 ++-- client/src/util/context/AlertContext.tsx | 48 ++++++ client/src/util/hooks/useAlert.tsx | 6 + client/src/util/types/alert.ts | 8 + 7 files changed, 243 insertions(+), 139 deletions(-) create mode 100644 client/src/components/AlertPopup.tsx create mode 100644 client/src/util/context/AlertContext.tsx create mode 100644 client/src/util/hooks/useAlert.tsx create mode 100644 client/src/util/types/alert.ts diff --git a/client/src/AdminDashboard/DeleteUserButton.tsx b/client/src/AdminDashboard/DeleteUserButton.tsx index ccf7f935..536fdbc9 100644 --- a/client/src/AdminDashboard/DeleteUserButton.tsx +++ b/client/src/AdminDashboard/DeleteUserButton.tsx @@ -1,51 +1,56 @@ -import React, { useState } from 'react'; -import Button from '@mui/material/Button'; -import { deleteUser } from './api'; -import LoadingButton from '../components/buttons/LoadingButton'; -import ConfirmationModal from '../components/ConfirmationModal'; - -interface DeleteUserButtonProps { - admin: boolean; - email: string; - removeRow: (user: string) => void; -} - -/** - * The button component which, when clicked, will delete the user from the database. - * If the user is an admin, the button will be unclickable. - * @param admin - whether the user is an admin - * @param email - the email of the user to delete - * @param removeRow - a function which removes a row from the user table. This - * function is called upon successfully deletion of user from the database. - */ -function DeleteUserButton({ admin, email, removeRow }: DeleteUserButtonProps) { - const [isLoading, setLoading] = useState(false); - async function handleDelete() { - setLoading(true); - if (await deleteUser(email)) { - removeRow(email); - } else { - setLoading(false); - } - } - if (isLoading) { - return ; - } - if (!admin) { - return ( - handleDelete()} - /> - ); - } - return ( - - ); -} - -export default DeleteUserButton; +import React, { useState } from 'react'; +import Button from '@mui/material/Button'; +import { deleteUser } from './api'; +import LoadingButton from '../components/buttons/LoadingButton'; +import ConfirmationModal from '../components/ConfirmationModal'; +import AlertType from '../util/types/alert'; +import useAlert from '../util/hooks/useAlert'; + +interface DeleteUserButtonProps { + admin: boolean; + email: string; + removeRow: (user: string) => void; +} + +/** + * The button component which, when clicked, will delete the user from the database. + * If the user is an admin, the button will be unclickable. + * @param admin - whether the user is an admin + * @param email - the email of the user to delete + * @param removeRow - a function which removes a row from the user table. This + * function is called upon successfully deletion of user from the database. + */ +function DeleteUserButton({ admin, email, removeRow }: DeleteUserButtonProps) { + const { setAlert } = useAlert(); + const [isLoading, setLoading] = useState(false); + async function handleDelete() { + setLoading(true); + if (await deleteUser(email)) { + removeRow(email); + console.log('here'); + setAlert(`User ${email} has been deleted.`, AlertType.SUCCESS); + } else { + setLoading(false); + } + } + if (isLoading) { + return ; + } + if (!admin) { + return ( + handleDelete()} + /> + ); + } + return ( + + ); +} + +export default DeleteUserButton; diff --git a/client/src/App.tsx b/client/src/App.tsx index 2b63d933..42d5004a 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,78 +1,80 @@ -import React from 'react'; -import { ThemeProvider } from '@mui/material/styles'; -import { CssBaseline } from '@mui/material'; -import { BrowserRouter, Routes, Route } from 'react-router-dom'; -import { Provider } from 'react-redux'; -import { PersistGate } from 'redux-persist/integration/react'; -import theme from './assets/theme'; -import { store, persistor } from './util/redux/store'; -import NotFoundPage from './NotFound/NotFoundPage'; -import HomePage from './Home/HomePage'; -import AdminDashboardPage from './AdminDashboard/AdminDashboardPage'; -import { - UnauthenticatedRoutesWrapper, - ProtectedRoutesWrapper, - DynamicRedirect, - AdminRoutesWrapper, -} from './util/routes'; -import VerifyAccountPage from './Authentication/VerifyAccountPage'; -import RegisterPage from './Authentication/RegisterPage'; -import LoginPage from './Authentication/LoginPage'; -import EmailResetPasswordPage from './Authentication/EmailResetPasswordPage'; -import ResetPasswordPage from './Authentication/ResetPasswordPage'; - -function App() { - return ( -
- - - - - - - {/* Routes accessed only if user is not authenticated */} - }> - } /> - } /> - } - /> - } - /> - } - /> - - {/* Routes accessed only if user is authenticated */} - }> - } /> - - }> - } /> - - - {/* Route which redirects to a different page depending on if the user is an authenticated or not by utilizing the DynamicRedirect component */} - - } - /> - - {/* Route which is accessed if no other route is matched */} - } /> - - - - - - -
- ); -} - -export default App; +import React from 'react'; +import { ThemeProvider } from '@mui/material/styles'; +import { CssBaseline } from '@mui/material'; +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { PersistGate } from 'redux-persist/integration/react'; +import theme from './assets/theme'; +import { store, persistor } from './util/redux/store'; +import NotFoundPage from './NotFound/NotFoundPage'; +import HomePage from './Home/HomePage'; +import AdminDashboardPage from './AdminDashboard/AdminDashboardPage'; +import { + UnauthenticatedRoutesWrapper, + ProtectedRoutesWrapper, + DynamicRedirect, + AdminRoutesWrapper, +} from './util/routes'; +import VerifyAccountPage from './Authentication/VerifyAccountPage'; +import RegisterPage from './Authentication/RegisterPage'; +import LoginPage from './Authentication/LoginPage'; +import EmailResetPasswordPage from './Authentication/EmailResetPasswordPage'; +import ResetPasswordPage from './Authentication/ResetPasswordPage'; +import AlertPopup from './components/AlertPopup'; + +function App() { + return ( +
+ + + + + + + + {/* Routes accessed only if user is not authenticated */} + }> + } /> + } /> + } + /> + } + /> + } + /> + + {/* Routes accessed only if user is authenticated */} + }> + } /> + + }> + } /> + + + {/* Route which redirects to a different page depending on if the user is an authenticated or not by utilizing the DynamicRedirect component */} + + } + /> + + {/* Route which is accessed if no other route is matched */} + } /> + + + + + + +
+ ); +} + +export default App; diff --git a/client/src/components/AlertPopup.tsx b/client/src/components/AlertPopup.tsx new file mode 100644 index 00000000..8fee14d3 --- /dev/null +++ b/client/src/components/AlertPopup.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { Alert, Snackbar } from '@mui/material'; +import useAlert from '../util/hooks/useAlert'; + +function AlertPopup() { + const { type, text } = useAlert(); + + if (text && type) { + return ( + + + {text} + + + ); + } + return
; +} + +export default AlertPopup; diff --git a/client/src/index.tsx b/client/src/index.tsx index d4ee7caa..bc881623 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -1,10 +1,19 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import reportWebVitals from './reportWebVitals'; -import App from './App'; - -ReactDOM.render(, document.getElementById('root')); -// If you want to start measuring performance in your app, pass a function -// to log results (for example: reportWebVitals(console.log)) -// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals -reportWebVitals(); +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import reportWebVitals from './reportWebVitals'; +import App from './App'; +import { AlertProvider } from './util/context/AlertContext'; + +const container = document.getElementById('root'); +// eslint-disable-next-line @typescript-eslint/no-non-null-assertion +const root = createRoot(container!); // createRoot(container!) if you use TypeScript +root.render( + + + , +); + +// If you want to start measuring performance in your app, pass a function +// to log results (for example: reportWebVitals(console.log)) +// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals +reportWebVitals(); diff --git a/client/src/util/context/AlertContext.tsx b/client/src/util/context/AlertContext.tsx new file mode 100644 index 00000000..706d3f98 --- /dev/null +++ b/client/src/util/context/AlertContext.tsx @@ -0,0 +1,48 @@ +import React, { createContext, useMemo, useState } from 'react'; +import { AnyChildren } from '../types/generic'; +import AlertType from '../types/alert'; + +interface Alert { + type: AlertType | undefined; + text: string; + setAlert: (text: string, type: AlertType) => void; +} + +const ALERT_TIME = 5000; + +const AlertContext = createContext({ + type: undefined, + text: '', + // eslint-disable-next-line @typescript-eslint/no-empty-function + setAlert: () => {}, +}); + +function AlertProvider({ children }: AnyChildren) { + const [notification, setNotification] = useState(); + const [notificationText, setNotificationText] = useState(''); + + const setAlert = (text: string, type: AlertType) => { + setNotification(type); + setNotificationText(text); + setTimeout(() => { + setNotification(undefined); + setNotificationText(''); + }, ALERT_TIME); + }; + + const providerContext: Alert = useMemo(() => { + return { + type: notification, + text: notificationText, + setAlert, + }; + }, [notificationText, notification]); + return ( + + {children} + + ); +} + +export { AlertProvider }; +export default AlertContext; diff --git a/client/src/util/hooks/useAlert.tsx b/client/src/util/hooks/useAlert.tsx new file mode 100644 index 00000000..94e5dad8 --- /dev/null +++ b/client/src/util/hooks/useAlert.tsx @@ -0,0 +1,6 @@ +import { useContext } from 'react'; +import AlertContext from '../context/AlertContext'; + +const useAlert = () => useContext(AlertContext); + +export default useAlert; diff --git a/client/src/util/types/alert.ts b/client/src/util/types/alert.ts new file mode 100644 index 00000000..3d82cc82 --- /dev/null +++ b/client/src/util/types/alert.ts @@ -0,0 +1,8 @@ +const enum AlertType { + ERROR = 'error', + WARNING = 'warning', + INFO = 'info', + SUCCESS = 'success', +} + +export default AlertType; From 1ff79a4b60331f0fcf24433f495b9752104b5517 Mon Sep 17 00:00:00 2001 From: Ben Demers Date: Fri, 20 Jan 2023 16:01:48 -0500 Subject: [PATCH 02/18] email invite --- .../src/AdminDashboard/AdminDashboardPage.tsx | 58 ++-- .../src/AdminDashboard/DeleteUserButton.tsx | 1 - client/src/App.tsx | 5 + .../src/Authentication/InviteRegisterPage.tsx | 283 ++++++++++++++++++ client/src/Authentication/api.ts | 233 +++++++------- .../components/buttons/InviteUserButton.tsx | 82 +++++ server/src/controllers/admin.controller.ts | 69 ++++- server/src/controllers/auth.controller.ts | 82 +++++ server/src/models/invite.model.ts | 29 ++ server/src/routes/admin.route.ts | 14 + server/src/routes/auth.route.ts | 7 + server/src/services/invite.service.ts | 77 +++++ server/src/services/mail.service.ts | 27 +- 13 files changed, 834 insertions(+), 133 deletions(-) create mode 100644 client/src/Authentication/InviteRegisterPage.tsx create mode 100644 client/src/components/buttons/InviteUserButton.tsx create mode 100644 server/src/models/invite.model.ts create mode 100644 server/src/services/invite.service.ts diff --git a/client/src/AdminDashboard/AdminDashboardPage.tsx b/client/src/AdminDashboard/AdminDashboardPage.tsx index 9738564e..499a01a7 100644 --- a/client/src/AdminDashboard/AdminDashboardPage.tsx +++ b/client/src/AdminDashboard/AdminDashboardPage.tsx @@ -1,29 +1,29 @@ -import React from 'react'; -import { Typography, Grid } from '@mui/material'; -import ScreenGrid from '../components/ScreenGrid'; -import UserTable from './UserTable'; - -/** - * A page only accessible to admins that displays all users in a table and allows - * Admin to delete users from admin and promote users to admin. - */ -function AdminDashboardPage() { - return ( - - - Welcome to the Admin Dashboard - -
- -
-
-
- ); -} - -export default AdminDashboardPage; +import React from 'react'; +import { Typography, Grid } from '@mui/material'; +import ScreenGrid from '../components/ScreenGrid'; +import UserTable from './UserTable'; +import InviteUserButton from '../components/buttons/InviteUserButton'; + +/** + * A page only accessible to admins that displays all users in a table and allows + * Admin to delete users from admin and promote users to admin. + */ +function AdminDashboardPage() { + return ( + + + Welcome to the Admin Dashboard + + + + + +
+ +
+
+
+ ); +} + +export default AdminDashboardPage; diff --git a/client/src/AdminDashboard/DeleteUserButton.tsx b/client/src/AdminDashboard/DeleteUserButton.tsx index 536fdbc9..2d4fddc9 100644 --- a/client/src/AdminDashboard/DeleteUserButton.tsx +++ b/client/src/AdminDashboard/DeleteUserButton.tsx @@ -27,7 +27,6 @@ function DeleteUserButton({ admin, email, removeRow }: DeleteUserButtonProps) { setLoading(true); if (await deleteUser(email)) { removeRow(email); - console.log('here'); setAlert(`User ${email} has been deleted.`, AlertType.SUCCESS); } else { setLoading(false); diff --git a/client/src/App.tsx b/client/src/App.tsx index 42d5004a..f98d9221 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -21,6 +21,7 @@ import LoginPage from './Authentication/LoginPage'; import EmailResetPasswordPage from './Authentication/EmailResetPasswordPage'; import ResetPasswordPage from './Authentication/ResetPasswordPage'; import AlertPopup from './components/AlertPopup'; +import InviteRegisterPage from './Authentication/InviteRegisterPage'; function App() { return ( @@ -49,6 +50,10 @@ function App() { element={} /> + } + /> {/* Routes accessed only if user is authenticated */} }> } /> diff --git a/client/src/Authentication/InviteRegisterPage.tsx b/client/src/Authentication/InviteRegisterPage.tsx new file mode 100644 index 00000000..b0e51d26 --- /dev/null +++ b/client/src/Authentication/InviteRegisterPage.tsx @@ -0,0 +1,283 @@ +import React, { useEffect, useState } from 'react'; +import { Link, TextField, Grid, Typography } from '@mui/material'; +import { useNavigate, Link as RouterLink, useParams } from 'react-router-dom'; +import FormCol from '../components/form/FormCol'; +import { + emailRegex, + InputErrorMessage, + nameRegex, + passwordRegex, +} from '../util/inputvalidation'; +import { registerInvite } from './api'; +import AlertDialog from '../components/AlertDialog'; +import PrimaryButton from '../components/buttons/PrimaryButton'; +import ScreenGrid from '../components/ScreenGrid'; +import FormRow from '../components/form/FormRow'; +import FormGrid from '../components/form/FormGrid'; +import { useData } from '../util/api'; + +/** + * A page users visit to be able to register for a new account by inputting + * fields such as their name, email, and password. + */ +function InviteRegisterPage() { + const { token } = useParams(); + const navigate = useNavigate(); + + // Default values for state + const defaultValues = { + firstName: '', + lastName: '', + email: '', + password: '', + confirmPassword: '', + }; + const defaultShowErrors = { + firstName: false, + lastName: false, + email: false, + password: false, + confirmPassword: false, + alert: false, + }; + const defaultErrorMessages = { + firstName: '', + lastName: '', + email: '', + password: '', + confirmPassword: '', + alert: '', + }; + type ValueType = keyof typeof values; + + // State values and hooks + const [values, setValueState] = useState(defaultValues); + const [showError, setShowErrorState] = useState(defaultShowErrors); + const [errorMessage, setErrorMessageState] = useState(defaultErrorMessages); + const [alertTitle, setAlertTitle] = useState('Error'); + const [isRegistered, setRegistered] = useState(false); + const [validToken, setValidToken] = useState(true); + const [email, setEmail] = useState(''); + + // Helper functions for changing only one field in a state object + const setValue = (field: string, value: string) => { + setValueState((prevState) => ({ + ...prevState, + ...{ [field]: value }, + })); + }; + const setShowError = (field: string, show: boolean) => { + setShowErrorState((prevState) => ({ + ...prevState, + ...{ [field]: show }, + })); + }; + const setErrorMessage = (field: string, msg: string) => { + setErrorMessageState((prevState) => ({ + ...prevState, + ...{ [field]: msg }, + })); + }; + const invite = useData(`admin/invite/${token}`); + useEffect(() => { + if (!invite?.data && invite !== null) { + setValidToken(false); + } else { + setEmail(invite?.data?.email); + setValue('email', invite?.data?.email); + } + }, [invite]); + + const handleAlertClose = () => { + if (isRegistered) { + navigate('/login'); + } + setShowError('alert', false); + }; + + const clearErrorMessages = () => { + setShowErrorState(defaultShowErrors); + setErrorMessageState(defaultErrorMessages); + }; + + const validateInputs = () => { + clearErrorMessages(); + let isValid = true; + + // eslint-disable-next-line no-restricted-syntax, guard-for-in + for (const valueTypeString in values) { + const valueType = valueTypeString as ValueType; + if (!values[valueType]) { + setErrorMessage(valueTypeString, InputErrorMessage.MISSING_INPUT); + setShowError(valueTypeString, true); + isValid = false; + } + } + + if (!values.firstName.match(nameRegex)) { + setErrorMessage('firstName', InputErrorMessage.INVALID_NAME); + setShowError('firstName', true); + isValid = false; + } + if (!values.lastName.match(nameRegex)) { + setErrorMessage('lastName', InputErrorMessage.INVALID_NAME); + setShowError('lastName', true); + isValid = false; + } + if (!values.email.match(emailRegex)) { + setErrorMessage('email', InputErrorMessage.INVALID_EMAIL); + setShowError('email', true); + isValid = false; + } + if (!values.password.match(passwordRegex)) { + setErrorMessage('password', InputErrorMessage.INVALID_PASSWORD); + setShowError('password', true); + isValid = false; + } + if (!(values.confirmPassword === values.password)) { + setErrorMessage('confirmPassword', InputErrorMessage.PASSWORD_MISMATCH); + setShowError('confirmPassword', true); + isValid = false; + } + + return isValid; + }; + + async function handleSubmit() { + if (validateInputs() && token) { + registerInvite( + values.firstName, + values.lastName, + values.email, + values.password, + token, + ) + .then(() => { + setShowError('alert', true); + setAlertTitle(''); + setRegistered(true); + setErrorMessage('alert', 'Account created, please log in'); + }) + .catch((e) => { + setShowError('alert', true); + setErrorMessage('alert', e.message); + }); + } + } + + const title = "Let's get started"; + if (!validToken) { + return ( + + Invalid Invite Token + + ); + } + return ( + + + + + {title} + + + + + + + setValue('firstName', e.target.value)} + /> + + + setValue('lastName', e.target.value)} + /> + + + + + setValue('password', e.target.value)} + /> + + + setValue('confirmPassword', e.target.value)} + /> + + + + handleSubmit()} + > + Register + + + + + + Back to Login + + + + + {/* The alert that pops up */} + + + + + + ); +} + +export default InviteRegisterPage; diff --git a/client/src/Authentication/api.ts b/client/src/Authentication/api.ts index 95333a88..31ea9323 100644 --- a/client/src/Authentication/api.ts +++ b/client/src/Authentication/api.ts @@ -1,101 +1,132 @@ -/** - * A file for defining functions used to interact with the backend server - * for authentication purposes. - */ -import { postData } from '../util/api'; - -/** - * Sends a request to the server to log in a user - * @param email The email of the user to log in - * @param password The password for the user's account - * @throws An {@link Error} with a `messsage` field describing the issue in verifying - */ -async function loginUser(email: string, password: string) { - const lowercaseEmail = email.toLowerCase(); - const res = await postData('auth/login', { - email: lowercaseEmail, - password, - }); - if (res.error) { - throw Error(res.error.message); - } - return res.data; -} - -/** - * Sends a request to the server to verify an account - * @param verificationToken The token used to identify the verification attempt - * @throws An {@link Error} with a `messsage` field describing the issue in verifying - */ -async function verifyAccount(verificationToken: string) { - const res = await postData('auth/verify-account', { - token: verificationToken, - }); - if (res.error) { - throw Error(res.error.message); - } -} - -/** - * Sends a request to the server to register a user for an account - * @param firstName - * @param lastName - * @param email - * @param password - * @throws An {@link Error} with a `messsage` field describing the issue in verifying - */ -async function register( - firstName: string, - lastName: string, - email: string, - password: string, -) { - const lowercaseEmail = email.toLowerCase(); - const res = await postData('auth/register', { - firstName, - lastName, - email: lowercaseEmail, - password, - }); - if (res.error) { - throw Error(res.error.message); - } -} - -/** - * Sends a request to the server to email a reset password link to a user - * @param email The email of the user - * @throws An {@link Error} with a `messsage` field describing the issue in - * sending the email - */ -async function sendResetPasswordEmail(email: string) { - const lowercaseEmail = email.toLowerCase(); - const res = await postData('auth/send-reset-password-email', { - email: lowercaseEmail, - }); - if (res.error) { - throw Error(res.error.message); - } -} - -/** - * Sends a request to the server to reset a password for a user - * @param password The new password for the user - * @param token The token identifying the reset password attempt - * @throws An {@link Error} with a `messsage` field describing the issue in - * resetting the password - */ -async function resetPassword(password: string, token: string) { - const res = await postData('auth/reset-password', { password, token }); - if (res.error) { - throw Error(res.error.message); - } -} - -export { - register, - loginUser, - verifyAccount, - sendResetPasswordEmail, - resetPassword, -}; +/** + * A file for defining functions used to interact with the backend server + * for authentication purposes. + */ +import { postData } from '../util/api'; + +/** + * Sends a request to the server to log in a user + * @param email The email of the user to log in + * @param password The password for the user's account + * @throws An {@link Error} with a `messsage` field describing the issue in verifying + */ +async function loginUser(email: string, password: string) { + const lowercaseEmail = email.toLowerCase(); + const res = await postData('auth/login', { + email: lowercaseEmail, + password, + }); + if (res.error) { + throw Error(res.error.message); + } + return res.data; +} + +/** + * Sends a request to the server to verify an account + * @param verificationToken The token used to identify the verification attempt + * @throws An {@link Error} with a `messsage` field describing the issue in verifying + */ +async function verifyAccount(verificationToken: string) { + const res = await postData('auth/verify-account', { + token: verificationToken, + }); + if (res.error) { + throw Error(res.error.message); + } +} + +/** + * Sends a request to the server to register a user for an account + * @param firstName + * @param lastName + * @param email + * @param password + * @throws An {@link Error} with a `messsage` field describing the issue in verifying + */ +async function register( + firstName: string, + lastName: string, + email: string, + password: string, +) { + const lowercaseEmail = email.toLowerCase(); + const res = await postData('auth/register', { + firstName, + lastName, + email: lowercaseEmail, + password, + }); + if (res.error) { + throw Error(res.error.message); + } +} + +/** + * Sends a request to the server to email a reset password link to a user + * @param email The email of the user + * @throws An {@link Error} with a `messsage` field describing the issue in + * sending the email + */ +async function sendResetPasswordEmail(email: string) { + const lowercaseEmail = email.toLowerCase(); + const res = await postData('auth/send-reset-password-email', { + email: lowercaseEmail, + }); + if (res.error) { + throw Error(res.error.message); + } +} + +/** + * Sends a request to the server to reset a password for a user + * @param password The new password for the user + * @param token The token identifying the reset password attempt + * @throws An {@link Error} with a `messsage` field describing the issue in + * resetting the password + */ +async function resetPassword(password: string, token: string) { + const res = await postData('auth/reset-password', { password, token }); + if (res.error) { + throw Error(res.error.message); + } +} + +/** + * Sends a request to the server to register a new user via an invite + * @param firstName + * @param lastName + * @param email + * @param password + * @param inviteToken + * @throws An {@link Error} with a `messsage` field describing the issue in + * resetting the password + */ +async function registerInvite( + firstName: string, + lastName: string, + email: string, + password: string, + inviteToken: string, +) { + const lowercaseEmail = email.toLowerCase(); + const res = await postData('auth/register-invite', { + firstName, + lastName, + email: lowercaseEmail, + password, + inviteToken, + }); + if (res.error) { + throw Error(res.error.message); + } +} + +export { + register, + loginUser, + verifyAccount, + sendResetPasswordEmail, + resetPassword, + registerInvite, +}; diff --git a/client/src/components/buttons/InviteUserButton.tsx b/client/src/components/buttons/InviteUserButton.tsx new file mode 100644 index 00000000..8d9f4c84 --- /dev/null +++ b/client/src/components/buttons/InviteUserButton.tsx @@ -0,0 +1,82 @@ +import React, { useState } from 'react'; +import Button from '@mui/material/Button'; +import TextField from '@mui/material/TextField'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogContentText from '@mui/material/DialogContentText'; +import useAlert from '../../util/hooks/useAlert'; +import AlertType from '../../util/types/alert'; +import { postData } from '../../util/api'; + +function InviteUserButton() { + const [open, setOpen] = useState(false); + const [email, setEmail] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const { setAlert } = useAlert(); + + const handleClickOpen = () => { + setOpen(true); + }; + + const handleClose = () => { + setOpen(false); + }; + + const handleInvite = async () => { + setLoading(true); + postData('admin/invite', { email }).then((res) => { + if (res.error) { + setError(res.error.message); + } else { + setOpen(false); + } + setLoading(false); + setAlert(`${email} successfully invited!`, AlertType.SUCCESS); + }); + }; + + const updateEmail = (event: React.ChangeEvent) => { + setError(''); + setEmail(event.target.value); + }; + + return ( +
+ + + + + Please enter the email address of the user you would like to invite. + + + {error} + + + + + + + +
+ ); +} + +export default InviteUserButton; diff --git a/server/src/controllers/admin.controller.ts b/server/src/controllers/admin.controller.ts index 2fe9f0d4..22bcb7ef 100644 --- a/server/src/controllers/admin.controller.ts +++ b/server/src/controllers/admin.controller.ts @@ -3,6 +3,7 @@ * admin users such as getting all users, deleting users and upgrading users. */ import express from 'express'; +import crypto from 'crypto'; import ApiError from '../util/apiError'; import StatusCode from '../util/statusCode'; import { IUser } from '../models/user.model'; @@ -12,6 +13,14 @@ import { getAllUsersFromDB, deleteUserById, } from '../services/user.service'; +import { + createInvite, + getInviteByEmail, + getInviteByToken, + updateInvite, +} from '../services/invite.service'; +import { IInvite } from '../models/invite.model'; +import { emailInviteLink } from '../services/mail.service'; /** * Get all users from the database. Upon success, send the a list of all users in the res body with 200 OK status code. @@ -107,4 +116,62 @@ const deleteUser = async ( }); }; -export { getAllUsers, upgradePrivilege, deleteUser }; +const verifyToken = async ( + req: express.Request, + res: express.Response, + next: express.NextFunction, +) => { + const { token } = req.params; + getInviteByToken(token) + .then((invite) => { + if (invite) { + res.status(StatusCode.OK).send(invite); + } else { + next(ApiError.notFound('Unable to retrieve invite')); + } + }) + .catch(() => { + next(ApiError.internal('Error retrieving invite')); + }); +}; + +const inviteUser = async ( + req: express.Request, + res: express.Response, + next: express.NextFunction, +) => { + const { email } = req.body; + const emailRegex = + /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/g; + if (!email.match(emailRegex)) { + next(ApiError.badRequest('Invalid email')); + } + const lowercaseEmail = email.toLowerCase(); + const existingUser: IUser | null = await getUserByEmail(lowercaseEmail); + if (existingUser) { + next( + ApiError.badRequest( + `An account with email ${lowercaseEmail} already exists.`, + ), + ); + return; + } + + const existingInvite: IInvite | null = await getInviteByEmail(lowercaseEmail); + + try { + const verificationToken = crypto.randomBytes(32).toString('hex'); + if (existingInvite) { + await updateInvite(existingInvite, verificationToken); + } else { + await createInvite(lowercaseEmail, verificationToken); + } + + await emailInviteLink(lowercaseEmail, verificationToken); + res.sendStatus(StatusCode.CREATED); + } catch (err) { + next(ApiError.internal('Unable to invite user.')); + } +}; + +export { getAllUsers, upgradePrivilege, deleteUser, verifyToken, inviteUser }; diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index 42817e30..e91b648d 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -20,6 +20,11 @@ import { emailVerificationLink, } from '../services/mail.service'; import ApiError from '../util/apiError'; +import { + getInviteByToken, + removeInviteByToken, +} from '../services/invite.service'; +import { IInvite } from '../models/invite.model'; /** * A controller function to login a user and create a session with Passport. @@ -290,6 +295,82 @@ const resetPassword = async ( } }; +const registerInvite = async ( + req: express.Request, + res: express.Response, + next: express.NextFunction, +) => { + const { firstName, lastName, email, password, inviteToken } = req.body; + if (!firstName || !lastName || !email || !password) { + next( + ApiError.missingFields([ + 'firstName', + 'lastName', + 'email', + 'password', + 'inviteToken', + ]), + ); + return; + } + const emailRegex = + /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/g; + + const passwordRegex = /^[a-zA-Z0-9!?$%^*)(+=._-]{6,61}$/; + + const nameRegex = /^[a-z ,.'-]+/i; + + if ( + !email.match(emailRegex) || + !password.match(passwordRegex) || + !firstName.match(nameRegex) || + !lastName.match(nameRegex) + ) { + next(ApiError.badRequest('Invalid email, password, or name.')); + return; + } + + if (req.isAuthenticated()) { + next(ApiError.badRequest('Already logged in.')); + return; + } + + // Check if invite exists + const invite: IInvite | null = await getInviteByToken(inviteToken); + if (!invite || invite.email !== email) { + next(ApiError.badRequest(`Invalid invite`)); + return; + } + + const lowercaseEmail = email.toLowerCase(); + // Check if user exists + const existingUser: IUser | null = await getUserByEmail(lowercaseEmail); + if (existingUser) { + next( + ApiError.badRequest( + `An account with email ${lowercaseEmail} already exists.`, + ), + ); + return; + } + + // Create user + try { + const user = await createUser( + firstName, + lastName, + lowercaseEmail, + password, + ); + user!.verified = true; + await user?.save(); + await removeInviteByToken(inviteToken); + res.sendStatus(StatusCode.CREATED); + } catch (err) { + next(ApiError.internal('Unable to register user.')); + } +}; + export { login, logout, @@ -298,4 +379,5 @@ export { verifyAccount, sendResetPasswordEmail, resetPassword, + registerInvite, }; diff --git a/server/src/models/invite.model.ts b/server/src/models/invite.model.ts new file mode 100644 index 00000000..4b686e57 --- /dev/null +++ b/server/src/models/invite.model.ts @@ -0,0 +1,29 @@ +/** + * Defines the Invite model for the database and also the interface to + * access the model in TypeScript. + */ +import mongoose from 'mongoose'; + +const InviteSchema = new mongoose.Schema({ + email: { + type: String, + match: + /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/g, + }, + verificationToken: { + type: String, + required: false, + unique: true, + sparse: true, + }, +}); + +interface IInvite extends mongoose.Document { + _id: string; + email: string; + verificationToken: string; +} + +const Invite = mongoose.model('Invite', InviteSchema); + +export { IInvite, Invite }; diff --git a/server/src/routes/admin.route.ts b/server/src/routes/admin.route.ts index 6fd7b2a1..4fec325e 100644 --- a/server/src/routes/admin.route.ts +++ b/server/src/routes/admin.route.ts @@ -8,6 +8,8 @@ import { getAllUsers, upgradePrivilege, deleteUser, + inviteUser, + verifyToken, } from '../controllers/admin.controller'; import { isAuthenticated } from '../controllers/auth.middleware'; import { approve } from '../controllers/auth.controller'; @@ -51,4 +53,16 @@ router.put('/autopromote', upgradePrivilege); */ router.delete('/:email', isAuthenticated, isAdmin, deleteUser); +/** + * A POST route to invite a new user + * Expects a JSON body with the following fields: + * - email (string) - The email to invite the user from + */ +router.post('/invite', isAuthenticated, isAdmin, inviteUser); + +/** + * A GET route to verify the user invite is valid + */ +router.get('/invite/:token', verifyToken); + export default router; diff --git a/server/src/routes/auth.route.ts b/server/src/routes/auth.route.ts index 25975628..1d88a860 100644 --- a/server/src/routes/auth.route.ts +++ b/server/src/routes/auth.route.ts @@ -11,6 +11,7 @@ import { sendResetPasswordEmail, resetPassword, verifyAccount, + registerInvite, } from '../controllers/auth.controller'; import { isAuthenticated } from '../controllers/auth.middleware'; import 'dotenv/config'; @@ -66,4 +67,10 @@ router.post('/reset-password', resetPassword); */ router.get('/authstatus', isAuthenticated, approve); +/** + * A POST register a user from an invite. If the information and invite are valid + * a new account is created. Otherwise a 400 bad request error is returned + */ +router.post('/register-invite', registerInvite); + export default router; diff --git a/server/src/services/invite.service.ts b/server/src/services/invite.service.ts new file mode 100644 index 00000000..17347ba6 --- /dev/null +++ b/server/src/services/invite.service.ts @@ -0,0 +1,77 @@ +import { IInvite, Invite } from '../models/invite.model'; + +const removeSensitiveDataQuery = ['-verificationToken']; + +/** + * Creates a new invite in the database. + * @param email - string representing the email of the invited user + * @param verificationToken - string representing verification token + * @returns The created {@link Invite} + */ +const createInvite = async (email: string, verificationToken: string) => { + const newInvite = new Invite({ + email, + verificationToken, + }); + const invite = await newInvite.save(); + return invite; +}; + +/** + * Updates an existing invite in the database with a new verification token. + * @param oldInvite {@link Invite} - string representing the email of the invited user + * @param verificationToken - string representing verification token + * @returns The created {@link Invite} + */ +const updateInvite = async (oldInvite: IInvite, verificationToken: string) => { + const { _id, email } = oldInvite; + const newInvite = new Invite({ + _id, + email, + verificationToken, + }); + const invite = await Invite.findOneAndUpdate({ email }, newInvite).exec(); + return invite; +}; + +/** + * Fetch the invite associtated with the given email + * @param email - string representing the email of the invited user + * @returns The invite {@link Invite} + */ +const getInviteByEmail = async (email: string) => { + const invite = await Invite.findOne({ email }) + .select(removeSensitiveDataQuery) + .exec(); + return invite; +}; + +/** + * Fetch the invite associtated with the given token + * @param token - string representing the email of the invited user + * @returns The invite {@link Invite} + */ +const getInviteByToken = async (token: string) => { + const invite = await Invite.findOne({ verificationToken: token }).exec(); + return invite; +}; + +/** + * Delete the invite associtated with the given token + * @param token - string representing the email of the invited user + * @returns The deleted invite {@link Invite} + */ +const removeInviteByToken = async (token: string) => { + const invite = await Invite.findOneAndDelete({ + verificationToken: token, + }).exec(); + return invite; +}; + +export { + createInvite, + updateInvite, + getInviteByEmail, + getInviteByToken, + removeInviteByToken, +}; diff --git a/server/src/services/mail.service.ts b/server/src/services/mail.service.ts index 6f868316..95d0cb8a 100644 --- a/server/src/services/mail.service.ts +++ b/server/src/services/mail.service.ts @@ -64,4 +64,29 @@ const emailVerificationLink = async (email: string, token: string) => { await SGmail.send(mailSettings); }; -export { emailVerificationLink, emailResetPasswordLink }; +/** + * Sends an email with an invite link to create an account + * @param email The email of the user to send the link to + * @param token The unique token identifying this verification attempt + */ +const emailInviteLink = async (email: string, token: string) => { + const resetLink = `${baseUrl}/invite/${token}`; + const mailSettings: MailDataRequired = { + from: { + email: process.env.SENDGRID_EMAIL_ADDRESS || 'missing@mail.com', + name: senderName, + }, + to: email, + subject: 'Verify account', + html: + `

Please visit the following ` + + `link ` + + `to create your account for ${appName} and complete registration

` + + `

If you did not attempt to register an account with this email address, ` + + `please ignore this message.

`, + }; + // Send the email and propogate the error up if one exists + await SGmail.send(mailSettings); +}; + +export { emailVerificationLink, emailResetPasswordLink, emailInviteLink }; From 16bd1ef72da43f7552bcbefe75b67baa9b73e8b8 Mon Sep 17 00:00:00 2001 From: Ben Demers Date: Fri, 20 Jan 2023 16:05:00 -0500 Subject: [PATCH 03/18] add heroku postbuild --- package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index bbfb2635..2b40ba63 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "server" ], "scripts": { + "start": "node server/index.ts", "setup": "yarn install", "clean": "rm -rf node_modules && cd client && yarn clean && cd ../server && yarn clean", "dev": "cross-env NODE_ENV=development concurrently --kill-others-on-fail \"yarn server\" \"yarn client\"", @@ -20,7 +21,8 @@ "lint": "eslint --quiet --fix --ext .js,.ts,.tsx .", "prettier-check": "prettier --check .", "format": "prettier --write .", - "test": "cd server && yarn test" + "test": "cd server && yarn test", + "heroku-postbuild": "cd client && yarn install && yarn build" }, "dependencies": { "@emotion/react": "^11.7.1", From 297edcb8e0586a4658adc0a8096b7d43ba0aed22 Mon Sep 17 00:00:00 2001 From: Ben Demers Date: Fri, 20 Jan 2023 16:06:25 -0500 Subject: [PATCH 04/18] add node version --- package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/package.json b/package.json index 2b40ba63..5a2e05cd 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,9 @@ "repository": "https://github.com/hack4impact-upenn/boilerplate-s2022.git", "license": "MIT", "private": true, + "engines": { + "node": "14.8.x" + }, "workspaces": [ "client", "server" From de5111161d62f11b3665c3c9e5a15fbc16cda30a Mon Sep 17 00:00:00 2001 From: Ben Demers Date: Fri, 20 Jan 2023 16:06:34 -0500 Subject: [PATCH 05/18] add node version --- package.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/package.json b/package.json index 5a2e05cd..2b40ba63 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,6 @@ "repository": "https://github.com/hack4impact-upenn/boilerplate-s2022.git", "license": "MIT", "private": true, - "engines": { - "node": "14.8.x" - }, "workspaces": [ "client", "server" From a4c1f5820b241739e9b6f8dca5efb149d53ad808 Mon Sep 17 00:00:00 2001 From: Ben Demers Date: Fri, 20 Jan 2023 16:09:20 -0500 Subject: [PATCH 06/18] move invite alert --- client/src/components/buttons/InviteUserButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/buttons/InviteUserButton.tsx b/client/src/components/buttons/InviteUserButton.tsx index 8d9f4c84..bac20cb4 100644 --- a/client/src/components/buttons/InviteUserButton.tsx +++ b/client/src/components/buttons/InviteUserButton.tsx @@ -31,10 +31,10 @@ function InviteUserButton() { if (res.error) { setError(res.error.message); } else { + setAlert(`${email} successfully invited!`, AlertType.SUCCESS); setOpen(false); } setLoading(false); - setAlert(`${email} successfully invited!`, AlertType.SUCCESS); }); }; From 3c43b150d9622320e9f69cf62be7fbf3d0abcc31 Mon Sep 17 00:00:00 2001 From: Ben Demers Date: Fri, 20 Jan 2023 16:15:24 -0500 Subject: [PATCH 07/18] prettier on some files --- .../src/AdminDashboard/AdminDashboardPage.tsx | 58 ++-- .../src/AdminDashboard/DeleteUserButton.tsx | 110 ++++---- client/src/App.tsx | 170 +++++------ client/src/Authentication/api.ts | 264 +++++++++--------- client/src/index.tsx | 38 +-- 5 files changed, 320 insertions(+), 320 deletions(-) diff --git a/client/src/AdminDashboard/AdminDashboardPage.tsx b/client/src/AdminDashboard/AdminDashboardPage.tsx index 499a01a7..da1b51a5 100644 --- a/client/src/AdminDashboard/AdminDashboardPage.tsx +++ b/client/src/AdminDashboard/AdminDashboardPage.tsx @@ -1,29 +1,29 @@ -import React from 'react'; -import { Typography, Grid } from '@mui/material'; -import ScreenGrid from '../components/ScreenGrid'; -import UserTable from './UserTable'; -import InviteUserButton from '../components/buttons/InviteUserButton'; - -/** - * A page only accessible to admins that displays all users in a table and allows - * Admin to delete users from admin and promote users to admin. - */ -function AdminDashboardPage() { - return ( - - - Welcome to the Admin Dashboard - - - - - -
- -
-
-
- ); -} - -export default AdminDashboardPage; +import React from 'react'; +import { Typography, Grid } from '@mui/material'; +import ScreenGrid from '../components/ScreenGrid'; +import UserTable from './UserTable'; +import InviteUserButton from '../components/buttons/InviteUserButton'; + +/** + * A page only accessible to admins that displays all users in a table and allows + * Admin to delete users from admin and promote users to admin. + */ +function AdminDashboardPage() { + return ( + + + Welcome to the Admin Dashboard + + + + + +
+ +
+
+
+ ); +} + +export default AdminDashboardPage; diff --git a/client/src/AdminDashboard/DeleteUserButton.tsx b/client/src/AdminDashboard/DeleteUserButton.tsx index 2d4fddc9..a51de416 100644 --- a/client/src/AdminDashboard/DeleteUserButton.tsx +++ b/client/src/AdminDashboard/DeleteUserButton.tsx @@ -1,55 +1,55 @@ -import React, { useState } from 'react'; -import Button from '@mui/material/Button'; -import { deleteUser } from './api'; -import LoadingButton from '../components/buttons/LoadingButton'; -import ConfirmationModal from '../components/ConfirmationModal'; -import AlertType from '../util/types/alert'; -import useAlert from '../util/hooks/useAlert'; - -interface DeleteUserButtonProps { - admin: boolean; - email: string; - removeRow: (user: string) => void; -} - -/** - * The button component which, when clicked, will delete the user from the database. - * If the user is an admin, the button will be unclickable. - * @param admin - whether the user is an admin - * @param email - the email of the user to delete - * @param removeRow - a function which removes a row from the user table. This - * function is called upon successfully deletion of user from the database. - */ -function DeleteUserButton({ admin, email, removeRow }: DeleteUserButtonProps) { - const { setAlert } = useAlert(); - const [isLoading, setLoading] = useState(false); - async function handleDelete() { - setLoading(true); - if (await deleteUser(email)) { - removeRow(email); - setAlert(`User ${email} has been deleted.`, AlertType.SUCCESS); - } else { - setLoading(false); - } - } - if (isLoading) { - return ; - } - if (!admin) { - return ( - handleDelete()} - /> - ); - } - return ( - - ); -} - -export default DeleteUserButton; +import React, { useState } from 'react'; +import Button from '@mui/material/Button'; +import { deleteUser } from './api'; +import LoadingButton from '../components/buttons/LoadingButton'; +import ConfirmationModal from '../components/ConfirmationModal'; +import AlertType from '../util/types/alert'; +import useAlert from '../util/hooks/useAlert'; + +interface DeleteUserButtonProps { + admin: boolean; + email: string; + removeRow: (user: string) => void; +} + +/** + * The button component which, when clicked, will delete the user from the database. + * If the user is an admin, the button will be unclickable. + * @param admin - whether the user is an admin + * @param email - the email of the user to delete + * @param removeRow - a function which removes a row from the user table. This + * function is called upon successfully deletion of user from the database. + */ +function DeleteUserButton({ admin, email, removeRow }: DeleteUserButtonProps) { + const { setAlert } = useAlert(); + const [isLoading, setLoading] = useState(false); + async function handleDelete() { + setLoading(true); + if (await deleteUser(email)) { + removeRow(email); + setAlert(`User ${email} has been deleted.`, AlertType.SUCCESS); + } else { + setLoading(false); + } + } + if (isLoading) { + return ; + } + if (!admin) { + return ( + handleDelete()} + /> + ); + } + return ( + + ); +} + +export default DeleteUserButton; diff --git a/client/src/App.tsx b/client/src/App.tsx index f98d9221..63fab340 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,85 +1,85 @@ -import React from 'react'; -import { ThemeProvider } from '@mui/material/styles'; -import { CssBaseline } from '@mui/material'; -import { BrowserRouter, Routes, Route } from 'react-router-dom'; -import { Provider } from 'react-redux'; -import { PersistGate } from 'redux-persist/integration/react'; -import theme from './assets/theme'; -import { store, persistor } from './util/redux/store'; -import NotFoundPage from './NotFound/NotFoundPage'; -import HomePage from './Home/HomePage'; -import AdminDashboardPage from './AdminDashboard/AdminDashboardPage'; -import { - UnauthenticatedRoutesWrapper, - ProtectedRoutesWrapper, - DynamicRedirect, - AdminRoutesWrapper, -} from './util/routes'; -import VerifyAccountPage from './Authentication/VerifyAccountPage'; -import RegisterPage from './Authentication/RegisterPage'; -import LoginPage from './Authentication/LoginPage'; -import EmailResetPasswordPage from './Authentication/EmailResetPasswordPage'; -import ResetPasswordPage from './Authentication/ResetPasswordPage'; -import AlertPopup from './components/AlertPopup'; -import InviteRegisterPage from './Authentication/InviteRegisterPage'; - -function App() { - return ( -
- - - - - - - - {/* Routes accessed only if user is not authenticated */} - }> - } /> - } /> - } - /> - } - /> - } - /> - - } - /> - {/* Routes accessed only if user is authenticated */} - }> - } /> - - }> - } /> - - - {/* Route which redirects to a different page depending on if the user is an authenticated or not by utilizing the DynamicRedirect component */} - - } - /> - - {/* Route which is accessed if no other route is matched */} - } /> - - - - - - -
- ); -} - -export default App; +import React from 'react'; +import { ThemeProvider } from '@mui/material/styles'; +import { CssBaseline } from '@mui/material'; +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { PersistGate } from 'redux-persist/integration/react'; +import theme from './assets/theme'; +import { store, persistor } from './util/redux/store'; +import NotFoundPage from './NotFound/NotFoundPage'; +import HomePage from './Home/HomePage'; +import AdminDashboardPage from './AdminDashboard/AdminDashboardPage'; +import { + UnauthenticatedRoutesWrapper, + ProtectedRoutesWrapper, + DynamicRedirect, + AdminRoutesWrapper, +} from './util/routes'; +import VerifyAccountPage from './Authentication/VerifyAccountPage'; +import RegisterPage from './Authentication/RegisterPage'; +import LoginPage from './Authentication/LoginPage'; +import EmailResetPasswordPage from './Authentication/EmailResetPasswordPage'; +import ResetPasswordPage from './Authentication/ResetPasswordPage'; +import AlertPopup from './components/AlertPopup'; +import InviteRegisterPage from './Authentication/InviteRegisterPage'; + +function App() { + return ( +
+ + + + + + + + {/* Routes accessed only if user is not authenticated */} + }> + } /> + } /> + } + /> + } + /> + } + /> + + } + /> + {/* Routes accessed only if user is authenticated */} + }> + } /> + + }> + } /> + + + {/* Route which redirects to a different page depending on if the user is an authenticated or not by utilizing the DynamicRedirect component */} + + } + /> + + {/* Route which is accessed if no other route is matched */} + } /> + + + + + + +
+ ); +} + +export default App; diff --git a/client/src/Authentication/api.ts b/client/src/Authentication/api.ts index 31ea9323..6a0cbac3 100644 --- a/client/src/Authentication/api.ts +++ b/client/src/Authentication/api.ts @@ -1,132 +1,132 @@ -/** - * A file for defining functions used to interact with the backend server - * for authentication purposes. - */ -import { postData } from '../util/api'; - -/** - * Sends a request to the server to log in a user - * @param email The email of the user to log in - * @param password The password for the user's account - * @throws An {@link Error} with a `messsage` field describing the issue in verifying - */ -async function loginUser(email: string, password: string) { - const lowercaseEmail = email.toLowerCase(); - const res = await postData('auth/login', { - email: lowercaseEmail, - password, - }); - if (res.error) { - throw Error(res.error.message); - } - return res.data; -} - -/** - * Sends a request to the server to verify an account - * @param verificationToken The token used to identify the verification attempt - * @throws An {@link Error} with a `messsage` field describing the issue in verifying - */ -async function verifyAccount(verificationToken: string) { - const res = await postData('auth/verify-account', { - token: verificationToken, - }); - if (res.error) { - throw Error(res.error.message); - } -} - -/** - * Sends a request to the server to register a user for an account - * @param firstName - * @param lastName - * @param email - * @param password - * @throws An {@link Error} with a `messsage` field describing the issue in verifying - */ -async function register( - firstName: string, - lastName: string, - email: string, - password: string, -) { - const lowercaseEmail = email.toLowerCase(); - const res = await postData('auth/register', { - firstName, - lastName, - email: lowercaseEmail, - password, - }); - if (res.error) { - throw Error(res.error.message); - } -} - -/** - * Sends a request to the server to email a reset password link to a user - * @param email The email of the user - * @throws An {@link Error} with a `messsage` field describing the issue in - * sending the email - */ -async function sendResetPasswordEmail(email: string) { - const lowercaseEmail = email.toLowerCase(); - const res = await postData('auth/send-reset-password-email', { - email: lowercaseEmail, - }); - if (res.error) { - throw Error(res.error.message); - } -} - -/** - * Sends a request to the server to reset a password for a user - * @param password The new password for the user - * @param token The token identifying the reset password attempt - * @throws An {@link Error} with a `messsage` field describing the issue in - * resetting the password - */ -async function resetPassword(password: string, token: string) { - const res = await postData('auth/reset-password', { password, token }); - if (res.error) { - throw Error(res.error.message); - } -} - -/** - * Sends a request to the server to register a new user via an invite - * @param firstName - * @param lastName - * @param email - * @param password - * @param inviteToken - * @throws An {@link Error} with a `messsage` field describing the issue in - * resetting the password - */ -async function registerInvite( - firstName: string, - lastName: string, - email: string, - password: string, - inviteToken: string, -) { - const lowercaseEmail = email.toLowerCase(); - const res = await postData('auth/register-invite', { - firstName, - lastName, - email: lowercaseEmail, - password, - inviteToken, - }); - if (res.error) { - throw Error(res.error.message); - } -} - -export { - register, - loginUser, - verifyAccount, - sendResetPasswordEmail, - resetPassword, - registerInvite, -}; +/** + * A file for defining functions used to interact with the backend server + * for authentication purposes. + */ +import { postData } from '../util/api'; + +/** + * Sends a request to the server to log in a user + * @param email The email of the user to log in + * @param password The password for the user's account + * @throws An {@link Error} with a `messsage` field describing the issue in verifying + */ +async function loginUser(email: string, password: string) { + const lowercaseEmail = email.toLowerCase(); + const res = await postData('auth/login', { + email: lowercaseEmail, + password, + }); + if (res.error) { + throw Error(res.error.message); + } + return res.data; +} + +/** + * Sends a request to the server to verify an account + * @param verificationToken The token used to identify the verification attempt + * @throws An {@link Error} with a `messsage` field describing the issue in verifying + */ +async function verifyAccount(verificationToken: string) { + const res = await postData('auth/verify-account', { + token: verificationToken, + }); + if (res.error) { + throw Error(res.error.message); + } +} + +/** + * Sends a request to the server to register a user for an account + * @param firstName + * @param lastName + * @param email + * @param password + * @throws An {@link Error} with a `messsage` field describing the issue in verifying + */ +async function register( + firstName: string, + lastName: string, + email: string, + password: string, +) { + const lowercaseEmail = email.toLowerCase(); + const res = await postData('auth/register', { + firstName, + lastName, + email: lowercaseEmail, + password, + }); + if (res.error) { + throw Error(res.error.message); + } +} + +/** + * Sends a request to the server to email a reset password link to a user + * @param email The email of the user + * @throws An {@link Error} with a `messsage` field describing the issue in + * sending the email + */ +async function sendResetPasswordEmail(email: string) { + const lowercaseEmail = email.toLowerCase(); + const res = await postData('auth/send-reset-password-email', { + email: lowercaseEmail, + }); + if (res.error) { + throw Error(res.error.message); + } +} + +/** + * Sends a request to the server to reset a password for a user + * @param password The new password for the user + * @param token The token identifying the reset password attempt + * @throws An {@link Error} with a `messsage` field describing the issue in + * resetting the password + */ +async function resetPassword(password: string, token: string) { + const res = await postData('auth/reset-password', { password, token }); + if (res.error) { + throw Error(res.error.message); + } +} + +/** + * Sends a request to the server to register a new user via an invite + * @param firstName + * @param lastName + * @param email + * @param password + * @param inviteToken + * @throws An {@link Error} with a `messsage` field describing the issue in + * resetting the password + */ +async function registerInvite( + firstName: string, + lastName: string, + email: string, + password: string, + inviteToken: string, +) { + const lowercaseEmail = email.toLowerCase(); + const res = await postData('auth/register-invite', { + firstName, + lastName, + email: lowercaseEmail, + password, + inviteToken, + }); + if (res.error) { + throw Error(res.error.message); + } +} + +export { + register, + loginUser, + verifyAccount, + sendResetPasswordEmail, + resetPassword, + registerInvite, +}; diff --git a/client/src/index.tsx b/client/src/index.tsx index bc881623..fd0f1748 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -1,19 +1,19 @@ -import React from 'react'; -import { createRoot } from 'react-dom/client'; -import reportWebVitals from './reportWebVitals'; -import App from './App'; -import { AlertProvider } from './util/context/AlertContext'; - -const container = document.getElementById('root'); -// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -const root = createRoot(container!); // createRoot(container!) if you use TypeScript -root.render( - - - , -); - -// If you want to start measuring performance in your app, pass a function -// to log results (for example: reportWebVitals(console.log)) -// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals -reportWebVitals(); +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import reportWebVitals from './reportWebVitals'; +import App from './App'; +import { AlertProvider } from './util/context/AlertContext'; + +const container = document.getElementById('root'); +// eslint-disable-next-line @typescript-eslint/no-non-null-assertion +const root = createRoot(container!); // createRoot(container!) if you use TypeScript +root.render( + + + , +); + +// If you want to start measuring performance in your app, pass a function +// to log results (for example: reportWebVitals(console.log)) +// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals +reportWebVitals(); From 7564f2f5f489d01162e6294d809d08f88e83a503 Mon Sep 17 00:00:00 2001 From: Ben Demers Date: Fri, 20 Jan 2023 16:21:06 -0500 Subject: [PATCH 08/18] add env var for backend url --- client/src/util/api.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/src/util/api.tsx b/client/src/util/api.tsx index 18eaf3d4..2e127d34 100644 --- a/client/src/util/api.tsx +++ b/client/src/util/api.tsx @@ -41,7 +41,9 @@ async function resolve(promise: Promise) { /** * To UPDATE DURING DEPLOYMENT USING ENVIRONMENT VARIABLES */ -const BACKENDURL = 'http://localhost:4000'; +const BACKENDURL = process.env.PUBLIC_URL + ? process.env.PUBLIC_URL + : 'http://localhost:4000'; const URLPREFIX = `${BACKENDURL}/api`; From a4a25a4601d576d9191f8731cae688814cb9c66d Mon Sep 17 00:00:00 2001 From: Ben Demers Date: Fri, 20 Jan 2023 16:30:39 -0500 Subject: [PATCH 09/18] disable eslint in prod --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2b40ba63..5a5caafa 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "prettier-check": "prettier --check .", "format": "prettier --write .", "test": "cd server && yarn test", - "heroku-postbuild": "cd client && yarn install && yarn build" + "heroku-postbuild": "DISABLE_ESLINT_PLUGIN=true cd client && yarn install && yarn build" }, "dependencies": { "@emotion/react": "^11.7.1", From b62d2cfd41478bf2a3a580bf075a499cf8d5f000 Mon Sep 17 00:00:00 2001 From: Ben Demers Date: Sat, 21 Jan 2023 21:03:31 -0500 Subject: [PATCH 10/18] trying new build order --- .gitignore | 20 ++++++++++---------- package.json | 6 +++--- server/package.json | 1 + tsconfig.json | 3 ++- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index 26b03f93..c00c1c78 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,10 @@ -yarn-error.log -/node_modules - -.env -/.env - -.env.local -.env.development.local -.env.test.local -.env.production.local +yarn-error.log +/node_modules +/bin +.env +/.env + +.env.local +.env.development.local +.env.test.local +.env.production.local diff --git a/package.json b/package.json index 5a5caafa..c289028b 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,12 @@ "server" ], "scripts": { - "start": "node server/index.ts", + "start": "node server/dist/index.js", "setup": "yarn install", + "build": "cd server && yarn build", "clean": "rm -rf node_modules && cd client && yarn clean && cd ../server && yarn clean", "dev": "cross-env NODE_ENV=development concurrently --kill-others-on-fail \"yarn server\" \"yarn client\"", "server": "cd server && yarn start", - "postinstall": "husky install && echo \"Package install attempted in global directory\"", "client": "cd client && yarn start", "pre-commit": "yarn lint-staged", "pre-push": "yarn lint && yarn prettier-check && yarn test", @@ -22,7 +22,7 @@ "prettier-check": "prettier --check .", "format": "prettier --write .", "test": "cd server && yarn test", - "heroku-postbuild": "DISABLE_ESLINT_PLUGIN=true cd client && yarn install && yarn build" + "heroku-postbuild": "cd client && yarn install && yarn build" }, "dependencies": { "@emotion/react": "^11.7.1", diff --git a/server/package.json b/server/package.json index 27052546..d00c4a31 100644 --- a/server/package.json +++ b/server/package.json @@ -7,6 +7,7 @@ "test": "cross-env NODE_ENV=test jest --runInBand --verbose --detectOpenHandles --forceExit", "server": "cross-env NODE_ENV=development ts-node-dev --respawn index.ts", "start": "yarn server", + "build": "tsc --build", "clean": "rm -rf node_modules", "dev": "echo \"Please cd into the root directory to run dev \" && exit 1", "postinstall": "echo \"Package install attempted in server directory\"" diff --git a/tsconfig.json b/tsconfig.json index d3c05e2b..a150ffba 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,8 @@ "target": "es6" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, "lib": [ - "es2019" + "es2019", + "dom" ] /* Specify library files to be included in the compilation. */, // "allowJs": true, /* Allow javascript files to be compiled. */ // "checkJs": true, /* Report errors in .js files. */ From a4d3b60ef10ff04182ad2b9c3a85471a02c4afaa Mon Sep 17 00:00:00 2001 From: Ben Demers Date: Sat, 21 Jan 2023 21:09:45 -0500 Subject: [PATCH 11/18] move serve build into postbuild --- package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/package.json b/package.json index c289028b..d1523e91 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,6 @@ "scripts": { "start": "node server/dist/index.js", "setup": "yarn install", - "build": "cd server && yarn build", "clean": "rm -rf node_modules && cd client && yarn clean && cd ../server && yarn clean", "dev": "cross-env NODE_ENV=development concurrently --kill-others-on-fail \"yarn server\" \"yarn client\"", "server": "cd server && yarn start", @@ -22,7 +21,7 @@ "prettier-check": "prettier --check .", "format": "prettier --write .", "test": "cd server && yarn test", - "heroku-postbuild": "cd client && yarn install && yarn build" + "heroku-postbuild": "cd client && yarn install && yarn build && cd ../server && yarn build" }, "dependencies": { "@emotion/react": "^11.7.1", From 34ce97da01554e3e377271025f6d6116a1c8e9ce Mon Sep 17 00:00:00 2001 From: Ben Demers Date: Sat, 21 Jan 2023 21:14:50 -0500 Subject: [PATCH 12/18] server install command --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d1523e91..848e39c5 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "prettier-check": "prettier --check .", "format": "prettier --write .", "test": "cd server && yarn test", - "heroku-postbuild": "cd client && yarn install && yarn build && cd ../server && yarn build" + "heroku-postbuild": "cd client && yarn install && yarn build && cd ../server && yarn install && yarn build" }, "dependencies": { "@emotion/react": "^11.7.1", From b3847e09fc63d2d2b0ac51f4ba704f7d2e739cf7 Mon Sep 17 00:00:00 2001 From: Ben Demers Date: Sun, 22 Jan 2023 14:52:51 -0500 Subject: [PATCH 13/18] add ts files to compile --- server/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/tsconfig.json b/server/tsconfig.json index a02cec29..7ce8dc31 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -17,5 +17,5 @@ "declaration": true, "declarationMap": true // enables importers to jump to source }, - "include": ["src/**/*"] + "include": ["src/**/*", "./*.ts"] } From 7dd7565e069aac6d3cfa06f6f6849d50abfc445c Mon Sep 17 00:00:00 2001 From: Ben Demers Date: Sun, 22 Jan 2023 15:32:29 -0500 Subject: [PATCH 14/18] fe fix --- server/src/config/createExpressApp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/config/createExpressApp.ts b/server/src/config/createExpressApp.ts index 11edaffb..90b3a42a 100644 --- a/server/src/config/createExpressApp.ts +++ b/server/src/config/createExpressApp.ts @@ -60,7 +60,7 @@ const createExpressApp = (sessionStore: MongoStore): express.Express => { // Serving static files if (process.env.NODE_ENV === 'production') { - const root = path.join(__dirname, '..', 'client', 'build'); + const root = path.join(__dirname, '..', '..', 'client', 'build'); app.use(express.static(root)); app.get('*', (_: any, res: any) => { From 256ac0de05c79049d7880a3db6ad47f60ee390db Mon Sep 17 00:00:00 2001 From: Ben Demers Date: Sun, 22 Jan 2023 15:54:08 -0500 Subject: [PATCH 15/18] fix path pt2 --- server/src/config/createExpressApp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/config/createExpressApp.ts b/server/src/config/createExpressApp.ts index 90b3a42a..d9a16260 100644 --- a/server/src/config/createExpressApp.ts +++ b/server/src/config/createExpressApp.ts @@ -60,7 +60,7 @@ const createExpressApp = (sessionStore: MongoStore): express.Express => { // Serving static files if (process.env.NODE_ENV === 'production') { - const root = path.join(__dirname, '..', '..', 'client', 'build'); + const root = path.join(__dirname, '../../../../', 'client', 'build'); app.use(express.static(root)); app.get('*', (_: any, res: any) => { From f4178b9a3c3e0d54e66855f363ba837089e8a3a7 Mon Sep 17 00:00:00 2001 From: Ben Demers Date: Sun, 22 Jan 2023 17:34:28 -0500 Subject: [PATCH 16/18] testing --- client/src/util/api.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/src/util/api.tsx b/client/src/util/api.tsx index 2e127d34..92b6432a 100644 --- a/client/src/util/api.tsx +++ b/client/src/util/api.tsx @@ -41,11 +41,14 @@ async function resolve(promise: Promise) { /** * To UPDATE DURING DEPLOYMENT USING ENVIRONMENT VARIABLES */ +console.log(process.env.PUBLIC_URL) const BACKENDURL = process.env.PUBLIC_URL ? process.env.PUBLIC_URL : 'http://localhost:4000'; const URLPREFIX = `${BACKENDURL}/api`; +console.log(BACKENDURL); +console.log(URLPREFIX); /** * A function which makes a GET request to the server when given a url and returns the response data after it is resolved by the {@link resolve} function. From b3954b586dd6431554d72100e2b295b8cf789ca9 Mon Sep 17 00:00:00 2001 From: Ben Demers Date: Sun, 22 Jan 2023 18:17:53 -0500 Subject: [PATCH 17/18] install devdep --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 848e39c5..4e101fc4 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "prettier-check": "prettier --check .", "format": "prettier --write .", "test": "cd server && yarn test", - "heroku-postbuild": "cd client && yarn install && yarn build && cd ../server && yarn install && yarn build" + "heroku-postbuild": "cd client && yarn install && yarn build && cd ../server && yarn install --production=false && yarn build" }, "dependencies": { "@emotion/react": "^11.7.1", From c133ff29e2c269a341e2c73b1111f61909e1c2b6 Mon Sep 17 00:00:00 2001 From: Ben Demers Date: Mon, 23 Jan 2023 12:22:06 -0500 Subject: [PATCH 18/18] move mongomemeory to dep --- client/src/util/api.tsx | 3 --- server/package.json | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/client/src/util/api.tsx b/client/src/util/api.tsx index 92b6432a..2e127d34 100644 --- a/client/src/util/api.tsx +++ b/client/src/util/api.tsx @@ -41,14 +41,11 @@ async function resolve(promise: Promise) { /** * To UPDATE DURING DEPLOYMENT USING ENVIRONMENT VARIABLES */ -console.log(process.env.PUBLIC_URL) const BACKENDURL = process.env.PUBLIC_URL ? process.env.PUBLIC_URL : 'http://localhost:4000'; const URLPREFIX = `${BACKENDURL}/api`; -console.log(BACKENDURL); -console.log(URLPREFIX); /** * A function which makes a GET request to the server when given a url and returns the response data after it is resolved by the {@link resolve} function. diff --git a/server/package.json b/server/package.json index d00c4a31..db23cdb9 100644 --- a/server/package.json +++ b/server/package.json @@ -26,6 +26,7 @@ "express-session": "^1.17.2", "googleapis": "^108.0.0", "jsonwebtoken": "^8.5.1", + "mongodb-memory-server": "^8.2.0", "mongoose": "^6.1.10", "passport": "^0.6.0", "passport-local": "^1.0.0", @@ -48,7 +49,6 @@ "eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb-typescript-prettier": "^5.0.0", "jest": "^29.1.2", - "mongodb-memory-server": "^8.2.0", "prettier": "^2.5.1", "supertest": "^6.2.2", "ts-jest": "^29.0.3",