Skip to content

Commit

Permalink
[Closes #157, Closes #162] Update client routing implementation (#174)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
francisli authored Dec 20, 2024
1 parent c867d8b commit 9a15fd9
Show file tree
Hide file tree
Showing 11 changed files with 258 additions and 210 deletions.
191 changes: 33 additions & 158 deletions client/src/App.jsx
Original file line number Diff line number Diff line change
@@ -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<typeof RedirectProps>} 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 <Loader />;
}
return <Outlet />;
}

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<typeof ProtectedRouteProps>} 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) ? <Loader /> : 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 (
<>
<Routes>
<Route
element={
<Redirect
isLoading={isLoading}
isLoggedIn={isLoggedIn}
isLoggedInRequired={true}
/>
}
>
<Route
path="/admin/patients/generate"
element={<AdminPatientsGenerate />}
/>
<Route element={<Layout />}>
<Route path="/patients" element={<Patients />} />
<Route path="/patients/:patientId" element={<PatientDetails />} />
<Route
path="/patients/register/:patientId"
element={
user ? (
<ProtectedRoute
role={user?.role}
restrictedRoles={['FIRST_RESPONDER']}
message={'Patient does not exist.'}
>
<PatientRegistration />
</ProtectedRoute>
) : (
<Loader />
)
}
/>
useEffect(() => {
try {
handleRedirects(user, location, (to, options) =>
navigate(to, { ...options, replace: true }),
);
} catch {
handleLogout();
}
}, [handleRedirects, handleLogout, user, location, navigate]);

<Route path="/admin/users" element={<AdminUsers />} />
<Route
path="/admin/pending-users"
element={<AdminPendingUsers />}
/>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="*" element={<NotFound />} />
</Route>
</Route>
<Route
element={<Redirect isLoading={isLoading} isLoggedIn={isLoggedIn} />}
>
<Route path="/" element={<Home />} />
<Route element={<AuthLayout />}>
<Route path="/register" element={<Register />} />
<Route path="/register/:inviteId" element={<Register />} />
<Route path="/login" element={<Login />} />
<Route path="/password/forgot" element={<PasswordForgot />} />
<Route
path="/password/:passwordResetToken"
element={<PasswordReset />}
/>
<Route path="verify/:emailVerificationToken" element={<Verify />} />
</Route>
</Route>
</Routes>
return isLoading ? (
<Center w="100vw" h="100vh">
<Loader />
</Center>
) : (
<>
<Outlet />
<Notifications position="bottom-right" />
</>
);
}

App.propTypes = {
handleRedirects: PropTypes.func.isRequired,
};

export default App;
15 changes: 0 additions & 15 deletions client/src/components/NavBar.jsx

This file was deleted.

18 changes: 15 additions & 3 deletions client/src/components/Sidebar/Sidebar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,19 @@ export function Sidebar({ toggleSidebar }) {
*/
async function onLogout(event) {
event.preventDefault();
handleLogout();
await handleLogout();
}

return (
<>
<Stack justify="space-between" px="md" py="xl" w="100%" h="100%">
<Stack
className={classes.navbar}
justify="space-between"
px="md"
py="xl"
w="100%"
h="100%"
>
<Box>
<Group align="center" gap="sm" mb="lg">
<img
Expand Down Expand Up @@ -122,7 +129,12 @@ export function Sidebar({ toggleSidebar }) {
</Box>
))}
</Box>
<Group className={classes.footer} justify="space-between" align="top">
<Group
className={classes.footer}
justify="space-between"
align="top"
wrap="nowrap"
>
<Box fz="sm">
{user && (
<>
Expand Down
3 changes: 2 additions & 1 deletion client/src/components/Sidebar/Sidebar.module.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
.navbar {
border-right: 1px solid var(--mantine-color-gray-3);
min-width: 19.5rem;
}

.navbar__icon {
Expand All @@ -10,6 +10,7 @@
.footer {
border-top: 1px solid var(--mantine-color-gray-3);
padding: 1.25rem 0 0;
min-height: 4rem;
}

.footer__logout {
Expand Down
14 changes: 3 additions & 11 deletions client/src/hooks/useAuthorization.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useContext, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useNavigate } from 'react-router';

import Context from '../Context';

Expand All @@ -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) => {
Expand All @@ -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();
Expand All @@ -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,
Expand Down
26 changes: 12 additions & 14 deletions client/src/main.jsx
Original file line number Diff line number Diff line change
@@ -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(
<React.StrictMode>
<ContextProvider>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<MantineProvider theme={theme}>
<Notifications position="bottom-right" />
<App />
</MantineProvider>
</BrowserRouter>
<MantineProvider theme={theme}>
<RouterProvider router={router}></RouterProvider>
</MantineProvider>
</QueryClientProvider>
</ContextProvider>
</React.StrictMode>,
Expand Down
Loading

0 comments on commit 9a15fd9

Please sign in to comment.