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/client/src/AdminDashboard/AdminDashboardPage.tsx b/client/src/AdminDashboard/AdminDashboardPage.tsx index 9738564e..da1b51a5 100644 --- a/client/src/AdminDashboard/AdminDashboardPage.tsx +++ b/client/src/AdminDashboard/AdminDashboardPage.tsx @@ -2,6 +2,7 @@ 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 @@ -10,14 +11,13 @@ import UserTable from './UserTable'; function AdminDashboardPage() { return ( - + Welcome to the Admin Dashboard - + + + + +
diff --git a/client/src/AdminDashboard/DeleteUserButton.tsx b/client/src/AdminDashboard/DeleteUserButton.tsx index ccf7f935..a51de416 100644 --- a/client/src/AdminDashboard/DeleteUserButton.tsx +++ b/client/src/AdminDashboard/DeleteUserButton.tsx @@ -3,6 +3,8 @@ 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; @@ -19,11 +21,13 @@ interface DeleteUserButtonProps { * 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); } diff --git a/client/src/App.tsx b/client/src/App.tsx index 2b63d933..63fab340 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -20,6 +20,8 @@ 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 ( @@ -29,6 +31,7 @@ function App() { + {/* Routes accessed only if user is not authenticated */} }> @@ -47,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..6a0cbac3 100644 --- a/client/src/Authentication/api.ts +++ b/client/src/Authentication/api.ts @@ -92,10 +92,41 @@ async function resetPassword(password: string, token: string) { } } +/** + * 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/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/components/buttons/InviteUserButton.tsx b/client/src/components/buttons/InviteUserButton.tsx new file mode 100644 index 00000000..bac20cb4 --- /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 { + setAlert(`${email} successfully invited!`, AlertType.SUCCESS); + setOpen(false); + } + setLoading(false); + }); + }; + + 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/client/src/index.tsx b/client/src/index.tsx index d4ee7caa..fd0f1748 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -1,9 +1,18 @@ import React from 'react'; -import ReactDOM from 'react-dom'; +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( + + + , +); -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 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`; 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; diff --git a/package.json b/package.json index bbfb2635..4e101fc4 100644 --- a/package.json +++ b/package.json @@ -9,18 +9,19 @@ "server" ], "scripts": { + "start": "node server/dist/index.js", "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\"", "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", "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 && cd ../server && yarn install --production=false && yarn build" }, "dependencies": { "@emotion/react": "^11.7.1", diff --git a/server/package.json b/server/package.json index 27052546..db23cdb9 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\"" @@ -25,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", @@ -47,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", diff --git a/server/src/config/createExpressApp.ts b/server/src/config/createExpressApp.ts index 11edaffb..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) => { 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 }; 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"] } 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. */