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 (
+
+ );
+ };
+
+ 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 };