diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 75aa4858..aa16d2a6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -6509,16 +6509,11 @@ "integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==" }, "history": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", - "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/history/-/history-5.0.0.tgz", + "integrity": "sha512-3NyRMKIiFSJmIPdq7FxkNMJkQ7ZEtVblOQ38VtKaA0zZMW1Eo6Q6W8oDKEflr1kNNTItSnk4JMCO1deeSgbLLg==", "requires": { - "@babel/runtime": "^7.1.2", - "loose-envify": "^1.2.0", - "resolve-pathname": "^3.0.0", - "tiny-invariant": "^1.0.2", - "tiny-warning": "^1.0.0", - "value-equal": "^1.0.1" + "@babel/runtime": "^7.7.6" } }, "hmac-drbg": { @@ -11001,6 +10996,19 @@ "tiny-warning": "^1.0.0" }, "dependencies": { + "history": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", + "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", + "requires": { + "@babel/runtime": "^7.1.2", + "loose-envify": "^1.2.0", + "resolve-pathname": "^3.0.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0", + "value-equal": "^1.0.1" + } + }, "isarray": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", @@ -11028,6 +11036,21 @@ "react-router": "5.2.0", "tiny-invariant": "^1.0.2", "tiny-warning": "^1.0.0" + }, + "dependencies": { + "history": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", + "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", + "requires": { + "@babel/runtime": "^7.1.2", + "loose-envify": "^1.2.0", + "resolve-pathname": "^3.0.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0", + "value-equal": "^1.0.1" + } + } } }, "react-scripts": { diff --git a/frontend/package.json b/frontend/package.json index ab7f569b..61b59421 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,7 @@ "@testing-library/user-event": "^7.2.1", "bootstrap": "^4.5.0", "firebase": "^7.15.5", + "history": "^5.0.0", "react": "^16.13.1", "react-bootstrap": "1.0.1", "react-dom": "^16.13.1", diff --git a/frontend/src/components/App/index.js b/frontend/src/components/App/index.js index 9e07350b..e443ad03 100644 --- a/frontend/src/components/App/index.js +++ b/frontend/src/components/App/index.js @@ -1,5 +1,6 @@ import React from 'react'; import {BrowserRouter as Router, Route} from 'react-router-dom'; +import { AuthProvider, PrivateRoute } from '../Auth'; import LandingPage from '../Landing'; import SignInPage from '../SignIn' @@ -14,14 +15,16 @@ import * as ROUTES from '../../constants/routes'; class App extends React.Component { render() { return ( - -
- - - - -
-
+ + +
+ + + + +
+
+
); } }; diff --git a/frontend/src/components/Auth/AuthContext.js b/frontend/src/components/Auth/AuthContext.js new file mode 100644 index 00000000..b3a1a00a --- /dev/null +++ b/frontend/src/components/Auth/AuthContext.js @@ -0,0 +1,36 @@ +import React, { useState, useEffect } from 'react'; +import app from '../Firebase'; + +const AuthContext = React.createContext(); + +/** + * AuthProvider component that keeps track of the authentication status of the + * current user. Allows global use of this status using React Context and should + * be wrapped around the contents of the App component. + */ +const AuthProvider = (props) => { + const [currentUser, setCurrentUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + // Set up listener that changes user status whenever it is updated. + app.auth().onAuthStateChanged((user) => { + setCurrentUser(user); + setIsLoading(false); + }); + }, []); + + if (isLoading) { + // TODO (Issue #25): Page initially displays "Loading..." for testing + // purposes, make this blank in the deployed build. + return (

Loading...

); + } + + return ( + + {props.children} + + ); +} + +export { AuthContext, AuthProvider }; diff --git a/frontend/src/components/Auth/AuthContext.test.js b/frontend/src/components/Auth/AuthContext.test.js new file mode 100644 index 00000000..4783a85e --- /dev/null +++ b/frontend/src/components/Auth/AuthContext.test.js @@ -0,0 +1,80 @@ +import React, { useContext } from 'react'; +import { render, act, screen, cleanup } from '@testing-library/react'; +import { AuthContext, AuthProvider } from './AuthContext.js'; + +jest.useFakeTimers(); + +// All times are in milliseconds. +const TIME_BEFORE_USER_IS_LOADED = 500; +const TIME_WHEN_USER_IS_LOADED = 1000; +const TIME_AFTER_USER_IS_LOADED = 2000; + +// Mock the the Firebase Auth onAuthStateChanged function, which pauses for the +// time given by TIME_WHEN_USER_IS_LOADED, then returns a fake user with only +// the property `name: 'Keiffer'`. +const mockOnAuthStateChanged = jest.fn(callback => { + setTimeout(() => { + callback({ name: 'Keiffer' }) + }, TIME_WHEN_USER_IS_LOADED); +}); +jest.mock('firebase/app', () => { + return { + initializeApp: () => { + return { + auth: () => { + return { + onAuthStateChanged: mockOnAuthStateChanged + } + } + } + } + } +}); + +afterEach(cleanup); + +describe('AuthProvider component', () => { + beforeEach(() => { render() }); + + it('initially displays "Loading"', () => { + act(() => jest.advanceTimersByTime(TIME_BEFORE_USER_IS_LOADED)); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + + it('returns a provider when onAuthStateChanged is called', () => { + act(() => jest.advanceTimersByTime(TIME_AFTER_USER_IS_LOADED)); + expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); + }); +}); + +describe('AuthContext Consumer component', () => { + // A Consumer component for AuthContext that just displays the current + // user. + const TestAuthContextConsumerComponent = () => { + const currentUser = useContext(AuthContext); + + return ( +
+
{currentUser.name}
+
+ ); + }; + + beforeEach(() => { + render( + + + + ); + }); + + it('initially displays "Loading"', () => { + act(() => jest.advanceTimersByTime(TIME_BEFORE_USER_IS_LOADED)); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + + it('displays the current user when they are authenticated', () => { + act(() => jest.advanceTimersByTime(TIME_AFTER_USER_IS_LOADED)); + expect(screen.getByText('Keiffer')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/Auth/PrivateRoute.js b/frontend/src/components/Auth/PrivateRoute.js new file mode 100644 index 00000000..c06818e1 --- /dev/null +++ b/frontend/src/components/Auth/PrivateRoute.js @@ -0,0 +1,39 @@ +import React, { useContext } from 'react'; +import { Route, Redirect } from 'react-router-dom'; +import { AuthContext } from '../Auth'; + +import { SIGN_IN } from '../../constants/routes.js'; + +/** + * PrivateRoute component that functions similarly to the `Route` component, + * with the added check that determines if the user is currently signed in. + * + * This component takes the authentication status of the current user from + * AuthContext. If they are authenticated, they will be allowed to view the + * contents of the Route component. If they are not authenticated, they will be + * redirected to the SIGN_IN page. + * + * @param {Object} props The following props are expected: + * - component {React.Component} The component that `PrivateRoute` should render + * if the user is currently authenticated. + */ +const PrivateRoute = ({ component: RouteComponent, ...rest }) => { + const currentUser = useContext(AuthContext); + + // The rest of the props passed into `PrivateRoute` are given as props to the + // `Route` component as normal. The render prop is used to specify that, if + // the user is signed in, the given component to render should be rendered + // along with all the standard Route paths (URL path, etc.), and if the user + // is not signed in, a `Redirect` prop should be rendered instead. + return ( + + currentUser ? + : + } + /> + ); +} + +export default PrivateRoute; diff --git a/frontend/src/components/Auth/PrivateRoute.test.js b/frontend/src/components/Auth/PrivateRoute.test.js new file mode 100644 index 00000000..a01b1c9b --- /dev/null +++ b/frontend/src/components/Auth/PrivateRoute.test.js @@ -0,0 +1,50 @@ +import React from 'react'; +import { Router, Route } from 'react-router-dom'; +import { render, screen, cleanup } from '@testing-library/react'; +import { createMemoryHistory } from 'history'; +import PrivateRoute from './PrivateRoute.js'; + +import { SIGN_IN } from '../../constants/routes.js'; + +const history = createMemoryHistory(); + +// Mock the useContext function so that, when called in the PrivateRoute +// component, returns null the first time and a fake user the second time. +jest.mock('react', () => ( + { + ...(jest.requireActual('react')), + useContext: jest + .fn() + .mockReturnValueOnce(null) + .mockReturnValueOnce({ name: 'Keiffer' }) + } +)); + +describe('PrivateRoute component', () => { + const TestComponent = () => { + return ( +
Hello, World!
+ ); + }; + + beforeEach(() => { + render( + + + + + ); + }); + + afterEach(cleanup); + + // mockOnAuthStateChanged called first time, so user should be null. + it('redirects to SIGN_IN when the user is not authenticated', () => { + expect(history.location.pathname).toEqual(SIGN_IN); + }); + + // mockOnAuthStateChanged called second time, so user should not be null. + it('renders the given component when the user is authenticated', () => { + expect(screen.getByText('Hello, World!')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/Auth/index.js b/frontend/src/components/Auth/index.js new file mode 100644 index 00000000..6e7e2e4d --- /dev/null +++ b/frontend/src/components/Auth/index.js @@ -0,0 +1,4 @@ +import { AuthContext, AuthProvider } from './AuthContext.js'; +import PrivateRoute from './PrivateRoute.js'; + +export { AuthContext, AuthProvider, PrivateRoute };