Main file:
import React, { useContext, useEffect } from 'react';
import { Route, RouteProps, Routes, useLocation, useMatch } from 'react-router-dom';
import AuthenticationContext from './AuthenticationContext';
export type PrivateRouteProps = RouteProps & {
Element?: React.ComponentType<any>;
};
/* eslint-disable react/jsx-props-no-spreading */
function PrivateRoute({ children, path, Element, ...routePropsWithoutChildrenAndComponent }: any) {
const { authorize, authenticated, authenticating, callbackPath } =
useContext(AuthenticationContext);
const location = useLocation();
const match = useMatch(path?.toString() || '*');
useEffect(() => {
if (!authenticated && !authenticating && match && location.pathname !== callbackPath) {
const authorizeAsync = async () => {
authorize(location as unknown as URL);
};
authorizeAsync();
}
}, [authorize, match, location, authenticated, authenticating, callbackPath]);
if (!authenticated) {
return null;
}
return (
<Routes>
<Route
{...routePropsWithoutChildrenAndComponent}
element={Element ? <Element /> : children}
/>
</Routes>
);
}
export default PrivateRoute;
Test file:
/* eslint-disable react/prop-types */
import React from 'react';
import { render } from '@testing-library/react';
import { createMemoryHistory } from 'history';
import { unstable_HistoryRouter as Router } from 'react-router-dom';
import { PrivateRoute } from '..';
import AuthenticationContext, { AuthenticationContextProps } from '../AuthenticationContext';
describe('PrivateRoute', () => {
function renderWithRouterAndContext(
content: JSX.Element,
// eslint-disable-next-line @typescript-eslint/ban-types
{ location, context } = {} as { location: string; context: object },
) {
const history = createMemoryHistory({ initialEntries: [location] });
const defaultContext: AuthenticationContextProps = {
fetchToken: () => 'xyz' as any,
callbackPath: '/oauth',
setError: () => {},
authenticated: false,
authenticating: false,
authorize: () => {},
logout: () => {},
getToken: () => 'xyz',
};
const wrapper = ({ children }: { children: React.ReactNode }) => (
<AuthenticationContext.Provider value={{ ...defaultContext, ...context }}>
<Router history={history as any}>{children}</Router>
</AuthenticationContext.Provider>
);
return {
...render(content, { wrapper } as any),
history,
};
}
describe('when authenticated', () => {
it('can render children', () => {
const { container } = renderWithRouterAndContext(
<PrivateRoute path="/hello">
<h1>Hello</h1>
</PrivateRoute>,
{ location: '/hello', context: { callbackPath: '/oauth', authenticated: true } },
);
expect(container.innerHTML).toBe('<h1>Hello</h1>');
});
it('can render a component', () => {
function MyComponent() {
return <h1>Hey</h1>;
}
// eslint-disable-next-line react/jsx-no-bind
const { container } = renderWithRouterAndContext(<PrivateRoute component={MyComponent} />, {
location: '/hello',
context: { callbackPath: '/oauth', authenticated: true },
});
expect(container.innerHTML).toBe('<h1>Hey</h1>');
});
it('can invoke a render prop function', () => {
const { container } = renderWithRouterAndContext(
<PrivateRoute
render={({ history, location }: any) => <p>{${history.length} ${location.pathname}}</p>}
/>,
{ location: '/hello', context: { callbackPath: '/oauth', authenticated: true } },
);
expect(container.innerHTML).toBe('<p>1 /hello</p>');
});
});
describe('when unauthenticated', () => {
const authorize = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
describe('for a matching path', () => {
it('checks user authorization and does not render anything', () => {
const { container } = renderWithRouterAndContext(
<PrivateRoute path="/hello">
<h1>Hello</h1>
</PrivateRoute>,
{
location: '/hello',
context: { callbackPath: '/oauth', authenticated: false, authorize },
},
);
expect(container.innerHTML).toBe('');
expect(authorize).toHaveBeenCalledWith(expect.objectContaining({ pathname: '/hello' }));
});
});
describe('for an OAuth callback path', () => {
it('does not check user authorization and does not render anything', () => {
const { container } = renderWithRouterAndContext(
<PrivateRoute path="/">
<h1>Hello</h1>
</PrivateRoute>,
{
location: '/oauth?code=xyz&state=foo',
context: { callbackPath: '/oauth', authenticated: false, authorize },
},
);
expect(container.innerHTML).toBe('');
expect(authorize).not.toHaveBeenCalled();
});
});
describe('for a non-matching path', () => {
it('does not check user authorization', () => {
renderWithRouterAndContext(
<PrivateRoute path="/hello">
<h1>Hello</h1>
</PrivateRoute>,
{ location: '/hi', context: { callbackPath: '/oauth', authenticated: false, authorize } },
);
expect(authorize).not.toHaveBeenCalled();
});
});
describe('when authentication is in progress', () => {
it('does not check user authorization', () => {
renderWithRouterAndContext(
<PrivateRoute path="/hello">
<h1>Hello</h1>
</PrivateRoute>,
{
location: '/hi',
context: {
callbackPath: '/hello',
authenticating: true,
authorize,
},
},
);
expect(authorize).not.toHaveBeenCalled();
});
});
});
});
With the new version of react-router-dom v6 and @types/react-router-dom v5.3.3, after resolving some errors with the code changes it was giving the "hello" path not found error.
Error:
console.warn No routes matched location "/hello"
31 | );
32 | return {
> 33 | ...render(content, { wrapper } as any),
| ^
34 | history,
35 | };
36 | }
● PrivateRoute › when authenticated › can render children
expect(received).toBe(expected) // Object.is equality
Expected: "<h1>Hello</h1>"
Received: ""
44 | { location: '/hello', context: { callbackPath: '/oauth', authenticated: true } },
45 | );
> 46 | expect(container.innerHTML).toBe('<h1>Hello</h1>');
| ^
47 | });
48 |
49 | it('can render a component', () => {
at Object.<anonymous> (packages/auth/src/_tests_/PrivateRoute.test.tsx:46:35)
● PrivateRoute › when authenticated › can render a component
expect(received).toBe(expected) // Object.is equality
Expected: "<h1>Hey</h1>"
Received: ""
56 | context: { callbackPath: '/oauth', authenticated: true },
57 | });
> 58 | expect(container.innerHTML).toBe('<h1>Hey</h1>');
| ^
59 | });
60 |
61 | it('can invoke a render prop function', () => {
at Object.<anonymous> (packages/auth/src/_tests_/PrivateRoute.test.tsx:58:35)
● PrivateRoute › when authenticated › can invoke a render prop function
expect(received).toBe(expected) // Object.is equality
Expected: "<p>1 /hello</p>"
Received: ""
66 | { location: '/hello', context: { callbackPath: '/oauth', authenticated: true } },
67 | );
> 68 | expect(container.innerHTML).toBe('<p>1 /hello</p>');
| ^
69 | });
70 | });
71 |
Appriciate some suggessions, Thanks in advance
There is no Route
being rendered with a "/hello"
path
prop. The PrivateRoute
component doesn't pass the path
prop through to the Route
it is rendering. You are overcomplicating the PrivateRoute
component though. It should simply render an Outlet
for nested routes to render their element
content, or in the case of unauthenticated users, generally redirect them to a safe unprotected route.
Keeping your logic of rendering null
for unauthenticated users though:
const PrivateRoute = () => {
const {
authorize,
authenticated,
authenticating,
callbackPath
} = useContext(AuthenticationContext);
const location = useLocation();
useEffect(() => {
if (!authenticated
&& !authenticating
&& location.pathname !== callbackPath
) {
const authorizeAsync = async () => {
authorize(location as unknown as URL);
};
authorizeAsync();
}
}, [authorize, location, authenticated, authenticating, callbackPath]);
return authenticated ? <Outlet /> : null;
}
The unit tests should be refactored to render PrivateRoute
as a layout route component.
import React from 'react';
import { render } from '@testing-library/react';
import { createMemoryHistory } from 'history';
import { unstable_HistoryRouter as Router } from 'react-router-dom';
import { PrivateRoute } from '..';
import AuthenticationContext, {
AuthenticationContextProps
} from '../AuthenticationContext';
describe('PrivateRoute', () => {
function renderWithRouterAndContext(
content: JSX.Element,
// eslint-disable-next-line @typescript-eslint/ban-types
{ location, context } = {} as { location: string; context: object },
) {
const history = createMemoryHistory({ initialEntries: [location] });
const defaultContext: AuthenticationContextProps = {
fetchToken: () => 'xyz' as any,
callbackPath: '/oauth',
setError: () => {},
authenticated: false,
authenticating: false,
authorize: () => {},
logout: () => {},
getToken: () => 'xyz',
};
const wrapper = ({ children }: { children: React.ReactNode }) => (
<AuthenticationContext.Provider value={{ ...defaultContext, ...context }}>
<Router history={history as any}>{children}</Router>
</AuthenticationContext.Provider>
);
return {
...render(content, { wrapper } as any),
history,
};
}
describe('when authenticated', () => {
it('can render children', () => {
const { container } = renderWithRouterAndContext(
<Routes>
<Route element={<PrivateRoute />}>
<Route path="/hello" element={<h1>Hello</h1>} />
</Route>
</Routes>,
{
location: '/hello',
context: { callbackPath: '/oauth', authenticated: true }
},
);
expect(container.innerHTML).toBe('<h1>Hello</h1>');
});
it('can render a component', () => {
function MyComponent() {
return <h1>Hey</h1>;
}
// eslint-disable-next-line react/jsx-no-bind
const { container } = renderWithRouterAndContext(
<Routes>
<Route element={<PrivateRoute />}>
<Route path="/hello" element={<MyComponent />} />
</Route>
</Routes>,
{
location: '/hello',
context: { callbackPath: '/oauth', authenticated: true },
}
);
expect(container.innerHTML).toBe('<h1>Hey</h1>');
});
});
describe('when unauthenticated', () => {
const authorize = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
describe('for a matching path', () => {
it('checks user authorization and does not render anything', () => {
const { container } = renderWithRouterAndContext(
<Routes>
<Route element={<PrivateRoute />}>
<Route path="/hello" element={<h1>Hello</h1>} />
</Route>
</Routes>,
{
location: '/hello',
context: { callbackPath: '/oauth', authenticated: false, authorize },
},
);
expect(container.innerHTML).toBe('');
expect(authorize).toHaveBeenCalledWith(
expect.objectContaining({ pathname: '/hello' })
);
});
});
describe('for an OAuth callback path', () => {
it('does not check user authorization and does not render anything', () => {
const { container } = renderWithRouterAndContext(
<Routes>
<Route path="/oauth" element={<h1>OAuth</h1>} />
<Route element={<PrivateRoute />}>
<Route path="/hello" element={<h1>Hello</h1>} />
</Route>
</Routes>,
{
location: '/oauth?code=xyz&state=foo',
context: { callbackPath: '/oauth', authenticated: false, authorize },
},
);
expect(container.innerHTML).toBe('<h1>OAuth</h1>');
expect(authorize).not.toHaveBeenCalled();
});
});
describe('for a non-matching path', () => {
it('does not check user authorization', () => {
renderWithRouterAndContext(
<Routes>
<Route path="/hi" element={<h1>Hi</h1>} />
<Route element={<PrivateRoute />}>
<Route path="/hello" element={<h1>Hello</h1>} />
</Route>
</Routes>,
{
location: '/hi',
context: { callbackPath: '/oauth', authenticated: false, authorize }
},
);
expect(authorize).not.toHaveBeenCalled();
});
});
describe('when authentication is in progress', () => {
it('does not check user authorization', () => {
renderWithRouterAndContext(
<Routes>
<Route path="/hi" element={<h1>Hi</h1>} />
<Route element={<PrivateRoute />}>
<Route path="/hello" element={<h1>Hello</h1>} />
</Route>
</Routes>,
{
location: '/hi',
context: {
callbackPath: '/hello',
authenticating: true,
authorize,
},
},
);
expect(authorize).not.toHaveBeenCalled();
});
});
});
});