From 9a15fd9b71d7dfcd63d4ae417e8a93ac795c6bee Mon Sep 17 00:00:00 2001 From: Francis Li Date: Fri, 20 Dec 2024 09:55:55 -0800 Subject: [PATCH] [Closes #157, Closes #162] Update client routing implementation (#174) * Clean out dead code * Refactor Redirect component to separate file * Refactor ProtectedRoute component into its own file * First pass, rewriting route implementation- no auth/redirect handling yet * Tweak sidebar a bit * Tweak sidebar more * New redirect/auth protection implementation * Remove old code * Restore loader while performing initial auth check --- client/src/App.jsx | 191 +++--------------- client/src/components/NavBar.jsx | 15 -- client/src/components/Sidebar/Sidebar.jsx | 18 +- .../src/components/Sidebar/Sidebar.module.css | 3 +- client/src/hooks/useAuthorization.jsx | 14 +- client/src/main.jsx | 26 ++- client/src/pages/auth/login/login.jsx | 18 +- client/src/pages/home.jsx | 15 +- client/src/routes.jsx | 165 +++++++++++++++ client/src/stories/Header/Header.jsx | 1 - client/src/theme.js | 2 +- 11 files changed, 258 insertions(+), 210 deletions(-) delete mode 100644 client/src/components/NavBar.jsx create mode 100644 client/src/routes.jsx diff --git a/client/src/App.jsx b/client/src/App.jsx index 06cc3dc5..b4fbe618 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,187 +1,62 @@ -import React, { useContext, useEffect } from 'react'; -import { Outlet, Routes, Route, useLocation, useNavigate } from 'react-router'; -import { Loader } from '@mantine/core'; +import { useContext, useEffect, ReactElement } from 'react'; +import { useLocation, useNavigate, Outlet } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import PropTypes from 'prop-types'; -import { Layout } from './stories/Layout/Layout'; -import Home from './pages/home'; -import Login from './pages/auth/login/login'; -import Register from './pages/auth/register/register'; -import Dashboard from './pages/dashboard/Dashboard'; -import AdminPatientsGenerate from './pages/admin/patients/AdminPatientsGenerate'; -import NotFound from './pages/notFound/NotFound'; -import { AdminUsers } from './pages/admin/users/AdminUsers'; +import { Center, Loader } from '@mantine/core'; +import { Notifications } from '@mantine/notifications'; import Context from './Context'; -import AdminPendingUsers from './pages/admin/pending-users/AdminPendingUsers'; -import PasswordForgot from './pages/auth/password-forgot/passwordForgot'; -import PasswordReset from './pages/auth/password-reset/passwordReset'; -import AuthLayout from './stories/AuthLayout/AuthLayout'; -import Verify from './pages/verify/verify'; -import PatientRegistration from './pages/patients/register/PatientRegistration'; -import PatientDetails from './pages/patients/patient-details/PatientDetails'; -import Patients from './pages/patients/Patients'; - -const RedirectProps = { - isLoading: PropTypes.bool.isRequired, - isLoggedIn: PropTypes.bool.isRequired, - isLoggedInRequired: PropTypes.bool, -}; +import { useAuthorization } from './hooks/useAuthorization'; /** - * Redirects browser based on props - * @param {PropTypes.InferProps} props - * @returns {React.ReactElement} + * Top-level application component. * + * @param {PropTypes.func} handleRedirects + * @returns {ReactElement} */ -function Redirect({ isLoading, isLoggedIn, isLoggedInRequired }) { +function App({ handleRedirects }) { const location = useLocation(); const navigate = useNavigate(); - useEffect(() => { - if (!isLoading) { - if (isLoggedInRequired && !isLoggedIn) { - let redirectTo = `${location.pathname}`; - if (location.search) { - redirectTo = `${redirectTo}?${location.search}`; - } - navigate('/login', { state: { redirectTo } }); - } else if (!isLoggedInRequired && isLoggedIn) { - navigate('/dashboard'); - } - } - }, [isLoading, isLoggedIn, isLoggedInRequired, location, navigate]); - if (isLoading) { - return ; - } - return ; -} - -Redirect.propTypes = RedirectProps; - -const ProtectedRouteProps = { - role: PropTypes.string.isRequired, - restrictedRoles: PropTypes.arrayOf(PropTypes.string).isRequired, - destination: PropTypes.string, - message: PropTypes.string, - children: PropTypes.element.isRequired, -}; - -/** - * Protect route elements that don't allow for FIRST_RESPONDER role - * @param {PropTypes.InferProps} props - * @returns {React.ReactElement} - */ -function ProtectedRoute({ - restrictedRoles, - role, - destination = 'notFound', - message, - children, -}) { - const navigate = useNavigate(); - useEffect(() => { - if (restrictedRoles.includes(role)) { - if (destination === 'forbidden') { - navigate('/forbidden', { - replace: true, - }); - } else { - navigate('/not-found', { - replace: true, - state: { message }, - }); - } - } - }, [restrictedRoles, role, navigate, destination, message]); - - return restrictedRoles.includes(role) ? : children; -} -ProtectedRoute.propTypes = ProtectedRouteProps; - -/** - * Top-level application component. * - * @returns {React.ReactElement} - */ -function App() { const { user, setUser } = useContext(Context); + const { handleLogout } = useAuthorization(); + const { isLoading } = useQuery({ queryKey: ['user'], queryFn: () => { return fetch('/api/v1/users/me', { credentials: 'include' }) - .then((response) => response.json()) + .then((response) => (response.ok ? response.json() : null)) .then((newUser) => { setUser(newUser); return newUser; }); }, }); - const isLoggedIn = !isLoading && !!user?.id; - return ( - <> - - - } - > - } - /> - }> - } /> - } /> - - - - ) : ( - - ) - } - /> + useEffect(() => { + try { + handleRedirects(user, location, (to, options) => + navigate(to, { ...options, replace: true }), + ); + } catch { + handleLogout(); + } + }, [handleRedirects, handleLogout, user, location, navigate]); - } /> - } - /> - } /> - } /> - - - } - > - } /> - }> - } /> - } /> - } /> - } /> - } - /> - } /> - - - + return isLoading ? ( +
+ +
+ ) : ( + <> + + ); } +App.propTypes = { + handleRedirects: PropTypes.func.isRequired, +}; + export default App; diff --git a/client/src/components/NavBar.jsx b/client/src/components/NavBar.jsx deleted file mode 100644 index f74d622e..00000000 --- a/client/src/components/NavBar.jsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; - -/** - * Primary navigation component. - */ -function NavBar() { - return ( - - ); -} - -export default NavBar; diff --git a/client/src/components/Sidebar/Sidebar.jsx b/client/src/components/Sidebar/Sidebar.jsx index a988597c..a59b52e3 100644 --- a/client/src/components/Sidebar/Sidebar.jsx +++ b/client/src/components/Sidebar/Sidebar.jsx @@ -83,12 +83,19 @@ export function Sidebar({ toggleSidebar }) { */ async function onLogout(event) { event.preventDefault(); - handleLogout(); + await handleLogout(); } return ( <> - + ))} - + {user && ( <> diff --git a/client/src/components/Sidebar/Sidebar.module.css b/client/src/components/Sidebar/Sidebar.module.css index 2d75f89a..97e8c929 100644 --- a/client/src/components/Sidebar/Sidebar.module.css +++ b/client/src/components/Sidebar/Sidebar.module.css @@ -1,5 +1,5 @@ .navbar { - border-right: 1px solid var(--mantine-color-gray-3); + min-width: 19.5rem; } .navbar__icon { @@ -10,6 +10,7 @@ .footer { border-top: 1px solid var(--mantine-color-gray-3); padding: 1.25rem 0 0; + min-height: 4rem; } .footer__logout { diff --git a/client/src/hooks/useAuthorization.jsx b/client/src/hooks/useAuthorization.jsx index 5ad980e5..7f6af867 100644 --- a/client/src/hooks/useAuthorization.jsx +++ b/client/src/hooks/useAuthorization.jsx @@ -1,6 +1,5 @@ import { useContext, useState } from 'react'; import { useMutation } from '@tanstack/react-query'; -import { useNavigate } from 'react-router'; import Context from '../Context'; @@ -19,7 +18,6 @@ import Context from '../Context'; export function useAuthorization() { const { user, setUser } = useContext(Context); const [error, setError] = useState(null); - const navigate = useNavigate(); const loginMutation = useMutation({ mutationFn: async (credentials) => { @@ -37,10 +35,9 @@ export function useAuthorization() { return response; }); }, - onSuccess: async (data, { redirectTo }) => { + onSuccess: async (data) => { const result = await data.json(); setUser(result); - navigate(redirectTo ?? '/'); }, onError: async (error) => { const errorBody = await error.json(); @@ -54,17 +51,12 @@ export function useAuthorization() { }, onSuccess: () => { setUser(null); - navigate('/'); }, }); - const handleLogin = async (credentials) => { - loginMutation.mutate(credentials); - }; + const handleLogin = (credentials) => loginMutation.mutateAsync(credentials); - const handleLogout = async () => { - logoutMutation.mutate(); - }; + const handleLogout = () => logoutMutation.mutateAsync(); return { user, diff --git a/client/src/main.jsx b/client/src/main.jsx index b2ee37c8..6d2cfc8d 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -1,31 +1,29 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; -import { BrowserRouter } from 'react-router'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { createBrowserRouter, RouterProvider } from 'react-router'; import { MantineProvider } from '@mantine/core'; -import { Notifications } from '@mantine/notifications'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +import routes from './routes'; +import theme from './theme'; + +import { ContextProvider } from './Context.jsx'; import '@mantine/core/styles.css'; import '@mantine/notifications/styles.css'; import '@mantine/dates/styles.css'; -import App from './App.jsx'; -import { theme } from './theme'; - -import { ContextProvider } from './Context.jsx'; - const queryClient = new QueryClient({}); +const router = createBrowserRouter(routes); + ReactDOM.createRoot(document.getElementById('root')).render( - - - - - - + + + , diff --git a/client/src/pages/auth/login/login.jsx b/client/src/pages/auth/login/login.jsx index 1769250c..5fb737d3 100644 --- a/client/src/pages/auth/login/login.jsx +++ b/client/src/pages/auth/login/login.jsx @@ -1,11 +1,12 @@ -import React, { useEffect, useState } from 'react'; -import { useLocation } from 'react-router'; +import { useContext, useEffect, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router'; import { StatusCodes } from 'http-status-codes'; import { useAuthorization } from '../../../hooks/useAuthorization'; import { LoginForm } from './LoginForm'; import classes from '../form.module.css'; +import Context from '../../../Context'; /** * Login page component. @@ -15,16 +16,25 @@ function Login() { const [password, setPassword] = useState(''); const [emailError, setEmailError] = useState(null); const [passwordError, setPasswordError] = useState(null); + const { user } = useContext(Context); const { handleLogin, error } = useAuthorization(); const location = useLocation(); + const navigate = useNavigate(); + + const { from: redirectTo } = location.state ?? {}; const login = async () => { setEmailError(null); setPasswordError(null); - const { redirectTo } = location.state ?? {}; - await handleLogin({ email, password, redirectTo }); + await handleLogin({ email, password }); }; + useEffect(() => { + if (user) { + navigate(redirectTo ?? '/', { replace: true }); + } + }, [user, redirectTo, navigate]); + useEffect(() => { if (error && error.status != StatusCodes.OK) { switch (error.status) { diff --git a/client/src/pages/home.jsx b/client/src/pages/home.jsx index 604b3358..e8254607 100644 --- a/client/src/pages/home.jsx +++ b/client/src/pages/home.jsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { Link } from 'react-router'; +import { useContext, useEffect } from 'react'; +import { useNavigate, Link } from 'react-router'; import { Box, Button, @@ -10,10 +10,21 @@ import { Title, } from '@mantine/core'; +import Context from '../Context'; + /** * Home page component. */ function Home() { + const { user } = useContext(Context); + const navigate = useNavigate(); + + useEffect(() => { + if (user) { + navigate('/dashboard', { replace: true }); + } + }, [user, navigate]); + return ( <>
diff --git a/client/src/routes.jsx b/client/src/routes.jsx new file mode 100644 index 00000000..2a4f954f --- /dev/null +++ b/client/src/routes.jsx @@ -0,0 +1,165 @@ +import PropTypes from 'prop-types'; +import { matchPath } from 'react-router'; + +import App from './App'; + +import AuthLayout from './stories/AuthLayout/AuthLayout'; +import { Layout } from './stories/Layout/Layout'; + +import NotFound from './pages/notFound/NotFound'; + +import Home from './pages/home'; +import Login from './pages/auth/login/login'; +import Register from './pages/auth/register/register'; +import PasswordForgot from './pages/auth/password-forgot/passwordForgot'; +import PasswordReset from './pages/auth/password-reset/passwordReset'; +import Verify from './pages/verify/verify'; + +import Dashboard from './pages/dashboard/Dashboard'; + +import AdminPatientsGenerate from './pages/admin/patients/AdminPatientsGenerate'; +import { AdminUsers } from './pages/admin/users/AdminUsers'; + +import AdminPendingUsers from './pages/admin/pending-users/AdminPendingUsers'; +import PatientRegistration from './pages/patients/register/PatientRegistration'; +import PatientDetails from './pages/patients/patient-details/PatientDetails'; +import Patients from './pages/patients/Patients'; + +// roles in order of least to most privileged +export const ROLES = ['FIRST_RESPONDER', 'VOLUNTEER', 'STAFF', 'ADMIN']; + +// these are routes that require authentication and authorization by role +export const AUTH_ROUTES = [ + ['ADMIN', ['admin/users', 'admin/pending-users']], + ['STAFF', ['admin/patients/generate', 'patients']], + ['VOLUNTEER', ['patients/register/:patientId']], + ['FIRST_RESPONDER', ['dashboard', 'patients/:patientId']], +]; + +// these are routes that should sign out a user if one is already logged in +export const UNAUTH_ROUTES = [ + 'register', + 'register/:inviteId', + 'password/forgot', + 'password/:passwordResetToken', + 'verify/:emailVerificationToken', +]; + +/** + * Redirect handler compares specified location to routes configured above + * and checks the user role as appropriate. It invokes the callback if + * a redirect is needed, and throws an error if the user is already logged in + * but visits an unauthenticated route. + * @param {PropTypes.object} user + * @param {PropTypes.object} location + * @param {PropTypes.func} callback + */ +export function handleRedirects(user, location, callback) { + const { pathname, search } = location; + const from = `${pathname}${search}`; + let match; + // check authenticated routes + for (const [role, routes] of AUTH_ROUTES) { + for (const route of routes) { + match = matchPath(route, pathname); + if (match) { + if (!user) { + return callback('/login', { state: { from } }); + } else if (ROLES.indexOf(user.role) < ROLES.indexOf(role)) { + return callback('/forbidden', { state: { from } }); + } + break; + } + } + } + // check if we need to sign out for any unauthenticated routes + if (!match && user) { + for (const route of UNAUTH_ROUTES) { + match = matchPath(route, pathname); + if (match) { + throw new Error('User is already logged in'); + } + } + } +} + +export default [ + { + path: '/', + element: , + errorElement: , + children: [ + { + index: true, + element: , + }, + { + element: , + children: [ + { + path: 'login', + element: , + }, + { + path: 'password/forgot', + element: , + }, + { + path: 'password/:passwordResetToken', + element: , + }, + { + path: 'verify/:emailVerificationToken', + element: , + }, + { + path: 'register', + element: , + }, + { + path: 'register/:inviteId', + element: , + }, + ], + }, + { + element: , + children: [ + { + path: 'dashboard', + element: , + }, + { + path: 'patients', + children: [ + { + index: true, + element: , + }, + { + path: ':patientId', + element: , + }, + { + path: 'register/:patientId', + element: , + }, + ], + }, + { + path: 'admin/users', + element: , + }, + { + path: 'admin/pending-users', + element: , + }, + ], + }, + { + path: 'admin/patients/generate', + element: , + }, + ], + }, +]; diff --git a/client/src/stories/Header/Header.jsx b/client/src/stories/Header/Header.jsx index 45eaed40..0cc72e4c 100644 --- a/client/src/stories/Header/Header.jsx +++ b/client/src/stories/Header/Header.jsx @@ -47,7 +47,6 @@ export function Header({ user = null }) { opened={drawerOpened} onClose={closeDrawer} size="100%" - title="Navigation" hiddenFrom="sm" zIndex={1} position="left" diff --git a/client/src/theme.js b/client/src/theme.js index f72ed6d3..572a88b4 100644 --- a/client/src/theme.js +++ b/client/src/theme.js @@ -1,3 +1,3 @@ import { createTheme } from '@mantine/core'; -export const theme = createTheme({}); +export default createTheme({});