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..0fde1b02 100644 --- a/frontend/src/components/App/index.js +++ b/frontend/src/components/App/index.js @@ -1,5 +1,7 @@ import React from 'react'; import {BrowserRouter as Router, Route} from 'react-router-dom'; +import { AuthProvider, PrivateRoute } from '../Auth'; +import { AuthUtilsConsumer } from '../AuthUtils'; import LandingPage from '../Landing'; import SignInPage from '../SignIn' @@ -14,14 +16,18 @@ 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 }; 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 ( +
+ Your Profile +

+ {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. */} + SLURP Logo + + + {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; +}