+
+
+
);
}
};
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 };
diff --git a/frontend/src/components/AuthUtils/AuthUtilsConsumer.js b/frontend/src/components/AuthUtils/AuthUtilsConsumer.js
new file mode 100644
index 00000000..76af95ec
--- /dev/null
+++ b/frontend/src/components/AuthUtils/AuthUtilsConsumer.js
@@ -0,0 +1,30 @@
+import React, { useContext } from 'react';
+import { AuthContext } from '../Auth';
+
+let currentUser = null;
+
+/**
+ * React Context Consumer component that only gets the value of the current
+ * authenticated user as given by the AuthProvider component. Intended to be
+ * wrapped around the App component's Router component as a child of the
+ * AuthProvider component.
+ *
+ * @param {Object} props No arguments are expected for this component's props.
+ * The child components are simply passed through.
+ */
+export const AuthUtilsConsumer = (props) => {
+ currentUser = useContext(AuthContext);
+ return (
+
{props.children}
+ );
+};
+
+/**
+ * Returns the current authenticated user set by the AuthUtilsConsumer
+ * component.
+ *
+ * @returns {firebase.User} The currently authenticated user.
+ */
+export function getCurrentUser() {
+ return currentUser;
+}
diff --git a/frontend/src/components/AuthUtils/index.js b/frontend/src/components/AuthUtils/index.js
new file mode 100644
index 00000000..cf357b55
--- /dev/null
+++ b/frontend/src/components/AuthUtils/index.js
@@ -0,0 +1,86 @@
+/**
+ * Utility functions to get various parameters corresponding to the currently
+ * authenticated user. These functions should ONLY be called from private pages,
+ * i.e. the user must be authenticated to ensure the parameters can be
+ * retrieved.
+ */
+
+import app from '../Firebase';
+import { AuthUtilsConsumer, getCurrentUser } from './AuthUtilsConsumer.js';
+import { SIGN_IN } from '../../constants/routes.js';
+
+/**
+ * Called when one of the authentication utility functions are used when the
+ * user has not logged in yet. Since the utility functions require that they be
+ * called from private pages, this function being called implies that the user
+ * managed to access a private page without being authenticated first. So they
+ * are immiedately redirected to the SIGN_IN page.
+ */
+function redirectToSignIn() {
+ window.location.href = SIGN_IN;
+}
+
+/**
+ * Checks that the user is logged in by seeing if the current user is set to
+ * null (which is what Firebase Auth returns if the user is not logged in). If
+ * not, they are redirected to the SIGN_IN page.
+ *
+ * @returns {Boolean} True if the user is signed in, false otherwise.
+ */
+function isUserLoggedIn() {
+ if (getCurrentUser() === null) {
+ redirectToSignIn();
+ return false;
+ }
+ return true;
+}
+
+/**
+ * @returns {String} The user's display name.
+ */
+export function getCurUserDisplayName() {
+ if (!isUserLoggedIn()) { return null; }
+ return getCurrentUser().displayName;
+}
+
+/**
+ * @returns {String} The user's email.
+ */
+export function getCurUserEmail() {
+ if (!isUserLoggedIn()) { return null; }
+ return getCurrentUser().email;
+}
+
+/**
+ * @returns {String} The user's profile picture URL.
+ */
+export function getCurUserPhotoUrl() {
+ if (!isUserLoggedIn()) { return null; }
+ return getCurrentUser().photoURL;
+}
+
+/**
+ * @returns {String} The user's unique ID.
+ */
+export function getCurUserUid() {
+ if (!isUserLoggedIn()) { return null; }
+ return getCurrentUser().uid;
+}
+
+export function signOut() {
+ app.auth().signOut();
+}
+
+// Can also access the auth functions in the named authUtils variable.
+const authUtils = {
+ getCurUserDisplayName,
+ getCurUserEmail,
+ getCurUserPhotoUrl,
+ getCurUserUid,
+ signOut
+};
+export default authUtils;
+
+// The AuthUtilsConsumer component must be imported into the App component to
+// allow these functions to be used.
+export { AuthUtilsConsumer };
diff --git a/frontend/src/components/AuthUtils/index.test.js b/frontend/src/components/AuthUtils/index.test.js
new file mode 100644
index 00000000..9b26ca90
--- /dev/null
+++ b/frontend/src/components/AuthUtils/index.test.js
@@ -0,0 +1,79 @@
+import authUtils from './index.js';
+import { getCurrentUser } from './AuthUtilsConsumer.js';
+import { SIGN_IN } from '../../constants/routes.js';
+
+const mockDisplayName = 'Sam Smith';
+const mockEmail = 'samsmith@email.com';
+const mockPhotoURL = 'http://www.photo-hosters.com/qwerty';
+const mockUid = '1234567890';
+const mockAuthenticatedUser = {
+ displayName: mockDisplayName,
+ email: mockEmail,
+ photoURL: mockPhotoURL,
+ uid: mockUid
+};
+const mockUnauthenticatedUser = null;
+
+jest.mock('./AuthUtilsConsumer.js', () => ({
+ getCurrentUser: jest.fn()
+}));
+
+describe('auth utility functions when authenticated', () => {
+ beforeEach(() => {
+ getCurrentUser.mockImplementation(() => mockAuthenticatedUser);
+ expect(getCurrentUser()).toBe(mockAuthenticatedUser);
+ })
+
+ test('getCurUserDisplayName function', () => {
+ expect(authUtils.getCurUserDisplayName()).toBe(mockDisplayName);
+ });
+
+ test('getCurUserEmail function', () => {
+ expect(authUtils.getCurUserEmail()).toBe(mockEmail);
+ });
+
+ test('getCurUserPhotoUrl function', () => {
+ expect(authUtils.getCurUserPhotoUrl()).toBe(mockPhotoURL);
+ });
+
+ test('getCurUserUid function', () => {
+ expect(authUtils.getCurUserUid()).toBe(mockUid);
+ });
+});
+
+// All utility functions in this scenario should do the same thing: redirect to
+// the SIGN_IN page.
+describe('auth utility functions when not authenticated', () => {
+ // Delete the default window.location object set by Jest to use my own mock.
+ beforeAll(() => {
+ delete window.location;
+ });
+
+ beforeEach(() => {
+ window.location = {
+ href: ''
+ }
+ getCurrentUser.mockImplementation(() => mockUnauthenticatedUser);
+ expect(getCurrentUser()).toBe(mockUnauthenticatedUser);
+ });
+
+ test('getCurUserDisplayName function', () => {
+ authUtils.getCurUserDisplayName();
+ expect(window.location.href).toBe(SIGN_IN);
+ });
+
+ test('getCurUserEmail function', () => {
+ authUtils.getCurUserEmail();
+ expect(window.location.href).toBe(SIGN_IN);
+ });
+
+ test('getCurUserPhotoUrl function', () => {
+ authUtils.getCurUserPhotoUrl();
+ expect(window.location.href).toBe(SIGN_IN);
+ });
+
+ test('getCurUserUid function', () => {
+ authUtils.getCurUserUid();
+ expect(window.location.href).toBe(SIGN_IN);
+ });
+});
diff --git a/frontend/src/components/Header/UserInfo.js b/frontend/src/components/Header/UserInfo.js
new file mode 100644
index 00000000..1a2bf20d
--- /dev/null
+++ b/frontend/src/components/Header/UserInfo.js
@@ -0,0 +1,41 @@
+import React from 'react';
+import authUtils from '../AuthUtils';
+
+import '../../styles/header.css';
+import Button from 'react-bootstrap/Button';
+
+// To set spacing between each element of the component.
+const BOOTSTRAP_SPACING_CLASS =
+ 'm-3 row justify-content-center align-self-center';
+
+/**
+ * Renders the user's profile picture (as provided by the service they used to
+ * log in), the user's display name, and a Sign Out button. This component is to
+ * be used with the Header component.
+ */
+class UserInfo extends React.Component {
+ /** @inheritdoc */
+ render() {
+ return (
+
+
+
+ {authUtils.getCurUserDisplayName()}
+
+
+
+ );
+ }
+}
+
+export default UserInfo;
diff --git a/frontend/src/components/Header/index.js b/frontend/src/components/Header/index.js
index de8947b4..2e41bcf8 100644
--- a/frontend/src/components/Header/index.js
+++ b/frontend/src/components/Header/index.js
@@ -1,13 +1,38 @@
import React from 'react';
+import UserInfo from './UserInfo.js';
+
+import '../../styles/header.css';
+import Container from 'react-bootstrap/Container';
+import Navbar from 'react-bootstrap/Navbar';
+
+import { VIEW_TRIPS } from '../../constants/routes.js';
/**
- * Header component.
+ * Header component to display at the top of pages. By default, it displays the
+ * SLURP logo on the left and user info on the right. It accepts a React
+ * component, rendering whatever it was given in the middle of the header.
+ *
+ * This component must ONLY be called in PrivateRoute pages.
+ *
+ * @param {Object} props An optional React component can be passed as the child
+ * of this component. The Header component will then render the given child
+ * component in its center.
*/
class Header extends React.Component {
+ /** @inheritdoc */
render() {
return (
-
Header
+
+
+ {/* TODO (Issue #24): Put path to logo when we have one. */}
+
+
+
+ {this.props.children}
+
+
+
);
}
diff --git a/frontend/src/components/Utils/filter-input.js b/frontend/src/components/Utils/filter-input.js
index edea6c30..e32419cd 100644
--- a/frontend/src/components/Utils/filter-input.js
+++ b/frontend/src/components/Utils/filter-input.js
@@ -28,7 +28,7 @@ export function getCleanedTextInput(rawInput, defaultValue) {
export function getCollaboratorUidArray(collaboratorEmailArr) {
collaboratorEmailArr = [getCurUserEmail()].concat(collaboratorEmailArr);
- // Removes empty fields (temporary).
+ // Removes empty fields (temporary until fix #67 & #72).
while (collaboratorEmailArr.includes('')) {
const emptyStrIdx = collaboratorEmailArr.indexOf('');
collaboratorEmailArr.splice(emptyStrIdx, 1);
diff --git a/frontend/src/components/ViewTrips/trip.js b/frontend/src/components/ViewTrips/trip.js
index fc4a3b9a..bc8db4d9 100644
--- a/frontend/src/components/ViewTrips/trip.js
+++ b/frontend/src/components/ViewTrips/trip.js
@@ -6,8 +6,8 @@ import { getUserEmailFromUid } from '../Utils/temp-auth-utils.js'
import ViewActivitiesButton from './view-activities-button.js';
/**
- * Returns the date range of the trip associated with the Trip document data
- * `tripObj`.
+ * Returns the string date range of the trip associated with the Trip document
+ * data `tripObj`.
*
* Notes:
* - tripObj will always contain valid start_date and end_date fields.
@@ -17,7 +17,7 @@ import ViewActivitiesButton from './view-activities-button.js';
*
* @param {firebase.firestore.DocumentData} tripObj Object containing the fields
* and values for a Trip document.
- * @return Date range of the trip (if it exists).
+ * @return {string} Date range of the trip.
*/
export function getDateRange(tripObj) {
const startDate = tripObj.start_date.toDate();
@@ -27,6 +27,13 @@ export function getDateRange(tripObj) {
`${endDate.getDate()}/${endDate.getFullYear()}`;
}
+/**
+ *
+ * @param {!Array} collaboratorUidArr Array of collaborator uids
+ * stored in trip document.
+ * @returns {string} Collaborator emails in comma separated string.
+ * Ex: "person1@email.com, person2@email.com".
+ */
export function getCollaboratorEmails(collaboratorUidArr) {
const collaboratorEmailArr =
collaboratorUidArr.map(uid => getUserEmailFromUid(uid));
diff --git a/frontend/src/styles/header.css b/frontend/src/styles/header.css
new file mode 100644
index 00000000..0e3ea7ea
--- /dev/null
+++ b/frontend/src/styles/header.css
@@ -0,0 +1,5 @@
+img {
+ border-radius: 50%;
+ height: 50px;
+ width: 50px;
+}