diff --git a/.gitignore b/.gitignore index c518367..e238c91 100644 --- a/.gitignore +++ b/.gitignore @@ -8,15 +8,17 @@ # Testing /coverage -# Productiom +# React Build Files /build -# Misc -.DS_Store +# React local environment files .env.local .env.development.local .env.test.local .env.production.local + +# Misc +.DS_Store .vscode/ # Node Package Management @@ -28,3 +30,4 @@ yarn-error.log* /api/venv __pycache__/ venv-manager.log +/api/.env \ No newline at end of file diff --git a/api/api.py b/api/api.py index f77e7fc..0cbd771 100644 --- a/api/api.py +++ b/api/api.py @@ -1,7 +1,17 @@ -from flask import Flask +from flask import Flask, request, after_this_request from flask_restful import Api, Resource +from flask_jwt_extended import ( + JWTManager, create_access_token, create_refresh_token, + jwt_required, get_jwt_identity, jwt_refresh_token_required, + set_refresh_cookies, unset_refresh_cookies +) +from werkzeug.security import check_password_hash +import os, dotenv import ECNQueue +# Load envrionment variables for ./.env +dotenv.load_dotenv() + # Create Flask App app = Flask(__name__) @@ -9,11 +19,96 @@ api = Api(app) +################################################################################ +# Configure Flask-JWT-Extended +################################################################################ + +# Set JWT secret key and create JWT manager +app.config["JWT_SECRET_KEY"] = os.environ.get("JWT_SECRET_KEY") +# Set identity claim field key to sub for JWT RFC complience +# Flask-JWT-Extended uses 'identity' by default for compatibility reasons +app.config["JWT_IDENTITY_CLAIM"] = "sub" +# Set the key for error messages generated by Flask-JWT-Extended +app.config["JWT_ERROR_MESSAGE_KEY"] = "message" + +# Look for JWTs in headers (for access) then cookies (for refresh) +app.config["JWT_TOKEN_LOCATION"] = ["headers", "cookies"] +# Restrict cookies to HTTPS in prod, allow HTTP in dev +app.config["JWT_COOKIE_SECURE"] = False if os.environ.get("ENVIRONMENT") == "dev" else True +# Restrict cookies using SameSite=strict flag +app.config["JWT_COOKIE_SAMESITE"] = "strict" +# Restrict refresh tokens to /token/refresh endpoint +app.config["JWT_REFRESH_COOKIE_PATH"] = '/tokens/refresh' +# Set the cookie key for CRSF validation string +# This is the default value. Adding it for easy reference +app.config["JWT_REFRESH_CSRF_HEADER_NAME"] = "X-CSRF-TOKEN" + +tokenManager = JWTManager(app) + + + +class Login(Resource): + def post(self) -> tuple: + """Validates username/password and returns both access and refresh tokens. + + Return Codes: + 200 (OK): On success. + 401 (Unauthroized): When username or password are incorrect. + 422 (Unprocessable Entitiy): When the username or password can't be parsed. + + Example: + curl -X POST + -H "Content-Type: application/json" + -d '{"username": "bob", "password": "super_secret"}' + + { "access_token": fjr09hfp09h932jp9ruj3.3r8ihf8h0w8hr08ifhj804h8i.8h48ith08ity409hip0t4 } + + Returns: + tuple: Response containing tokens and HTTP response code. + """ + if not request.is_json: + return ({ "message": "JSON missing from request body"}, 422) + + data = request.json + + fields_to_check = ["username", "password"] + for field in fields_to_check: + if field not in data.keys(): + return ({ "message": f"{field} missing from request body"}, 422) + + if data["username"] != os.environ.get("SHARED_USERNAME"): + return ({ "message": "Username invalid"}, 401) + if not check_password_hash(os.environ.get("SHARED_PASSWORD_HASH"), data["password"]): + return ({ "message": "Password invalid"}, 401) + + access_token = create_access_token(data["username"]) + refresh_token = create_refresh_token(data["username"]) + + # This decorator is needed because Flask-RESTful's 'resourceful routing` + # doesn't allow for direct modification to the Flask response object. + # See: https://flask-restful.readthedocs.io/en/latest/quickstart.html#resourceful-routing + @after_this_request + def _does_this_work(response): + set_refresh_cookies(response, refresh_token) + return response + + return ({ "access_token": access_token }, 200) + +class RefreshAccessToken(Resource): + @jwt_refresh_token_required + def post(self): + username = get_jwt_identity() + access_token = create_access_token(username) + return ({"access_token": access_token}, 200) class Item(Resource): - def get(self, queue: str, number: int) -> str: + @jwt_required + def get(self, queue: str, number: int) -> tuple: """Returns the JSON representation of the item requested. + Return Codes: + 200 (OK): On success. + Example: /api/ce/100 returns: { @@ -38,32 +133,40 @@ def get(self, queue: str, number: int) -> str: item (int): The number of the item requested. Returns: - str: JSON representation of the item requested. + tuple: Item as JSON and HTTP response code. """ - return ECNQueue.Item(queue, number).toJson() + return (ECNQueue.Item(queue, number).toJson(), 200) class Queue(Resource): - def get(self, queue: str) -> str: + @jwt_required + def get(self, queues: str) -> tuple: """Returns the JSON representation of the queue requested. + Return Codes: + 200 (OK): On success. + Args: - queue (str): The queue requested. + queues (str): Plus (+) deliminited list of queues. Returns: - str: JSON representation of the queue requested. + tuple: Queues as JSON and HTTP response code. """ - queues_requested = queue.split("+") + queues_requested = queues.split("+") - queues = [] + queue_list = [] for queue in queues_requested: - queues.append(ECNQueue.Queue(queue).toJson()) + queue_list.append(ECNQueue.Queue(queue).toJson()) - return queues + return (queue_list, 200) class QueueList(Resource): - def get(self) -> list: + @jwt_required + def get(self) -> tuple: """Returns a list of dictionaries with the number of items in each queue. + Return Codes: + 200 (OK): On success. + Example: [ { @@ -77,15 +180,17 @@ def get(self) -> list: ] Returns: - list: Dictionaries with the number of items in each queue. + tuple: Queues and item counts as JSON and HTTP response code. """ - return ECNQueue.getQueueCounts() - -api.add_resource(QueueList, "/api/get_queues") -api.add_resource(Item, "/api//") -api.add_resource(Queue, "/api/") + return (ECNQueue.getQueueCounts(), 200) +api.add_resource(Login, "/login") +api.add_resource(RefreshAccessToken, "/tokens/refresh") +api.add_resource(Item, "/api//") +api.add_resource(Queue, "/api/") +api.add_resource(QueueList, "/api/get_queues") + if __name__ == "__main__": - app.run() + app.run() \ No newline at end of file diff --git a/api/requirements.txt b/api/requirements.txt index 8a009e1..1aaa908 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -3,6 +3,7 @@ astroid==2.4.2 click==7.1.2 Flask==1.1.2 Flask-RESTful==0.3.8 +Flask-JWT-Extended==3.24.1 gunicorn==20.0.4 isort==4.3.21 itsdangerous==1.1.0 @@ -12,6 +13,7 @@ MarkupSafe==1.1.1 mccabe==0.6.1 pylint==2.5.3 python-dateutil==2.8.1 +python-dotenv==0.15.0 pytz==2020.1 six==1.15.0 toml==0.10.1 diff --git a/package-lock.json b/package-lock.json index 0910693..214b506 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1840,6 +1840,11 @@ "@babel/types": "^7.3.0" } }, + "@types/cookie": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.3.3.tgz", + "integrity": "sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow==" + }, "@types/eslint-visitor-keys": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz", @@ -1854,6 +1859,15 @@ "@types/node": "*" } }, + "@types/hoist-non-react-statics": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", + "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", + "requires": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "@types/istanbul-lib-coverage": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz", @@ -12065,6 +12079,16 @@ "use-memo-one": "^1.1.1" } }, + "react-cookie": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/react-cookie/-/react-cookie-4.0.3.tgz", + "integrity": "sha512-cmi6IpdVgTSvjqssqIEvo779Gfqc4uPGHRrKMEdHcqkmGtPmxolGfsyKj95bhdLEKqMdbX8MLBCwezlnhkHK0g==", + "requires": { + "@types/hoist-non-react-statics": "^3.0.1", + "hoist-non-react-statics": "^3.0.0", + "universal-cookie": "^4.0.0" + } + }, "react-dev-utils": { "version": "10.2.1", "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-10.2.1.tgz", @@ -15762,6 +15786,15 @@ "unist-util-is": "^3.0.0" } }, + "universal-cookie": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-4.0.4.tgz", + "integrity": "sha512-lbRVHoOMtItjWbM7TwDLdl8wug7izB0tq3/YVKhT/ahB4VDvWMyvnADfnJI8y6fSvsjh51Ix7lTGC6Tn4rMPhw==", + "requires": { + "@types/cookie": "^0.3.3", + "cookie": "^0.4.0" + } + }, "universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", diff --git a/package.json b/package.json index 56190c6..5b524fd 100644 --- a/package.json +++ b/package.json @@ -15,11 +15,12 @@ "history": "^5.0.0", "material-table": "^1.63.1", "react": "^16.13.1", + "react-cookie": "^4.0.3", "react-dom": "^16.13.1", "react-relative-time": "0.0.7", + "react-router-dom": "^5.2.0", "react-scripts": "3.4.1", - "react-table": "^7.5.1", - "react-router-dom": "^5.2.0" + "react-table": "^7.5.1" }, "scripts": { "start:frontend": "react-scripts start", diff --git a/src/App.js b/src/App.js index 6c08ecf..c410707 100644 --- a/src/App.js +++ b/src/App.js @@ -1,134 +1,26 @@ -import React, { useState, useEffect } from "react"; +import React, { useState } from "react"; import { ThemeProvider } from "@material-ui/core/styles"; -import { Box, makeStyles, Paper } from "@material-ui/core"; -import { Route } from "react-router-dom"; -import clsx from "clsx"; import webqueueTheme from "./theme"; -import ItemTableAppBar from "./components/ItemTableAppBar/"; -import ItemTable from "./components/ItemTable/"; -import ItemViewAppBar from "./components/ItemViewAppBar/"; -import ItemView from "./components/ItemView/"; -import QueueSelector from "./components/QueueSelector/"; +import { Switch, Route } from "react-router-dom"; +import PrivateRoute from "./components/PrivateRoute/"; +import AppView from "./components/AppView/"; +import LoginForm from "./components/LoginForm/"; function App() { const [darkMode, setDarkMode] = useState(false); - const [activeItem, setActiveItem] = useState({}); - const [sidebarOpen, setSidebarOpen] = useState(false); - const [queues, setQueues] = useState([]); - const [items, setItems] = useState([]); - const [selectedQueues, setSelectedQueues] = useState([]); - const [queueCounts, setQueueCounts] = useState([]); - - useEffect( _ => { - async function getQueues(){ - if (selectedQueues.length > 0){ - let queuesToLoad = ""; - - for (let selectedQueue of selectedQueues){ - queuesToLoad += `+${selectedQueue.name}`; - } - - const apiResponse = await fetch(`/api/${queuesToLoad}`); - const queueJson = await apiResponse.json(); - setQueues(queueJson); - } else { - setQueues([]) - } - } - getQueues(); - }, [selectedQueues]); - - useEffect( _ => { - let tempItems = []; - for (let queue of queues){ - tempItems = tempItems.concat(queue.items); - } - setItems(tempItems); - }, [queues]); - - useEffect( _ => { - async function getQueueCounts(){ - const apiResponse = await fetch(`/api/get_queues`); - const queueCountJson = await apiResponse.json(); - setQueueCounts(queueCountJson); - }; - getQueueCounts(); - return _ => setQueueCounts([]); - }, [selectedQueues]); const theme = webqueueTheme(darkMode); - const transitionWidth = theme.transitions.create(["width"], { - duration: theme.transitions.duration.enteringScreen, - easing: theme.transitions.easing.easeInOut - }); - const useStyles = makeStyles({ - "leftCol": { - overflow: "auto", - width: "100vw", - height: "100vh", - transition: transitionWidth, - }, - "rightCol": { - overflow: "auto", - width: "0", - height: "100vh", - transition: transitionWidth, - scrollbarWidth: 0, - }, - "rightColShift": { - overflowY: "auto", - width: "100vw", - flexShrink: "0", - transition: transitionWidth - }, - [theme.breakpoints.up("md")]: { - "rightColShift": { - width: "40vw", - } - }, - }); - const classes = useStyles(); - - return ( + return ( - - - - - - console.log("Clicked!") }/> - - - - {items.length === 0 ? null : - { - const item = items.find((item) => { - return item.queue === match.params.queue && item.number === Number(match.params.number); - }); - - if (item === undefined) { - return ( - - ); - } - - setActiveItem(item); - - return ( - <> - - - - ); - } - } - /> - } - - + + + + + + + + ); } diff --git a/src/auth/index.js b/src/auth/index.js new file mode 100644 index 0000000..679b398 --- /dev/null +++ b/src/auth/index.js @@ -0,0 +1 @@ +export { login, refresh } from "./utilities"; \ No newline at end of file diff --git a/src/auth/utilities.js b/src/auth/utilities.js new file mode 100644 index 0000000..8800954 --- /dev/null +++ b/src/auth/utilities.js @@ -0,0 +1,59 @@ +/** Utility Functions for webqueue2 API */ + + + +/** + * Returns an access token to be used for authorization. + * @example + * login("janeDoe", "superSecretPassword") + * @param {String} username + * @param {String} password + * @returns {Boolean | String} An access token on success, `false` otherwise. + */ +export async function login(username, password){ + const loginInit = { + method: "POST", + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ "username": username, "password": password}) + }; + + let loginResponse = await fetch("/login", loginInit); + let data = await loginResponse.json(); + + if (data === null){ + return false; + } + if (!loginResponse.ok){ + console.error(`Login failed. Got code ${loginResponse.status} (${loginResponse.statusText})`); + return false; + } + + return data.access_token || false; +} + +/** + * Refresh current access token. + * @example + * refresh("csrf_refresh_token") + * @param {String} csrf_refresh_token The current CSRF validation string. + * @returns {Boolean | String} An access token on success, `false` otherwise. + */ +export async function refresh(csrf_refresh_token){ + const refreshInit = { + method: "POST", + headers: {'X-CSRF-TOKEN': csrf_refresh_token}, + }; + + let refreshResponse = await fetch("/tokens/refresh", refreshInit); + let data = await refreshResponse.json(); + + if (data === null){ + return false; + } + if (!refreshResponse.ok){ + console.error(`Refresh failed. Got code ${refreshResponse.status} (${refreshResponse.statusText})`); + return false; + } + + return data.access_token || false; +} \ No newline at end of file diff --git a/src/components/AppView/AppView.js b/src/components/AppView/AppView.js new file mode 100644 index 0000000..6fb0006 --- /dev/null +++ b/src/components/AppView/AppView.js @@ -0,0 +1,152 @@ +import React, { useState, useEffect } from "react"; +import PropTypes from "prop-types"; +import { Box, makeStyles, Paper, useTheme } from "@material-ui/core"; +import { Route } from "react-router-dom"; +import clsx from "clsx"; +import ItemTableAppBar from "../ItemTableAppBar/"; +import ItemTable from "../ItemTable/"; +import ItemViewAppBar from "../ItemViewAppBar/"; +import ItemView from "../ItemView/"; +import QueueSelector from "../QueueSelector/"; +import { useToken } from "../AuthProvider/"; + +export default function AppView({ setDarkMode }){ + const [activeItem, setActiveItem] = useState({}); + const [sidebarOpen, setSidebarOpen] = useState(false); + const [queues, setQueues] = useState([]); + const [items, setItems] = useState([]); + const [selectedQueues, setSelectedQueues] = useState([]); + const [queueCounts, setQueueCounts] = useState([]); + + const access_token = useToken(); + + useEffect( _ => { + async function getQueues(){ + if (access_token === null){ + return + } + + if (selectedQueues.length > 0){ + let queuesToLoad = ""; + + for (let selectedQueue of selectedQueues){ + queuesToLoad += `+${selectedQueue.name}`; + } + + let myHeaders = new Headers(); + myHeaders.append("Authorization", `Bearer ${access_token}`); + let requestOptions = { headers: myHeaders }; + + const apiResponse = await fetch(`/api/${queuesToLoad}`, requestOptions); + const queueJson = await apiResponse.json(); + setQueues(queueJson); + } else { + setQueues([]) + } + } + getQueues(); + }, [selectedQueues, access_token]); + + useEffect( _ => { + let tempItems = []; + for (let queue of queues){ + tempItems = tempItems.concat(queue.items); + } + setItems(tempItems); + }, [queues]); + + useEffect( _ => { + async function getQueueCounts(){ + if (access_token === null){ + return + } + + let myHeaders = new Headers(); + myHeaders.append("Authorization", `Bearer ${access_token}`); + let requestOptions = { headers: myHeaders }; + + const apiResponse = await fetch(`/api/get_queues`, requestOptions); + const queueCountJson = await apiResponse.json(); + setQueueCounts(queueCountJson); + }; + getQueueCounts(); + return _ => setQueueCounts([]); + }, [selectedQueues, access_token]); + + const theme = useTheme(); + const transitionWidth = theme.transitions.create(["width"], { + duration: theme.transitions.duration.enteringScreen, + easing: theme.transitions.easing.easeInOut + }); + const useStyles = makeStyles({ + "leftCol": { + overflow: "auto", + width: "100vw", + height: "100vh", + transition: transitionWidth, + }, + "rightCol": { + overflow: "auto", + width: "0", + height: "100vh", + transition: transitionWidth, + scrollbarWidth: 0, + }, + "rightColShift": { + overflowY: "auto", + width: "100vw", + flexShrink: "0", + transition: transitionWidth + }, + [theme.breakpoints.up("md")]: { + "rightColShift": { + width: "40vw", + } + }, + }); + const classes = useStyles(); + + return( + + + + + + console.log("Clicked!") }/> + + + + {items.length === 0 ? null : + { + const item = items.find((item) => { + return item.queue === match.params.queue && item.number === Number(match.params.number); + }); + + if (item === undefined) { + return ( + + ); + } + + setActiveItem(item); + + return ( + <> + + + + ); + } + } + /> + } + + + ); +}; + +AppView.propTypes = {}; + +AppView.defaultProps = {}; \ No newline at end of file diff --git a/src/components/AppView/AppView.md b/src/components/AppView/AppView.md new file mode 100644 index 0000000..9d4107e --- /dev/null +++ b/src/components/AppView/AppView.md @@ -0,0 +1,10 @@ +The primary view for webqueue2. + +--- +```jsx +import AppView from "./AppView"; + +``` +```jsx static + +``` \ No newline at end of file diff --git a/src/components/AppView/index.js b/src/components/AppView/index.js new file mode 100644 index 0000000..08cc8fb --- /dev/null +++ b/src/components/AppView/index.js @@ -0,0 +1 @@ +export { default } from "./AppView"; \ No newline at end of file diff --git a/src/components/AuthProvider/AuthProvider.js b/src/components/AuthProvider/AuthProvider.js new file mode 100644 index 0000000..ae5b6fa --- /dev/null +++ b/src/components/AuthProvider/AuthProvider.js @@ -0,0 +1,55 @@ +import React, { useState, createContext, useContext, useEffect } from "react"; +import { useCookies } from "react-cookie"; +import { refresh } from "../../auth/"; + + + +const LoginContext = createContext(); +const LoginSetterContext = createContext(); +const TokenContext = createContext(); +const TokenSetterContext = createContext(); + +export const useLogin = () => useContext(LoginContext); +export const useLoginSetter = () => useContext(LoginSetterContext); +export const useToken = () => useContext(TokenContext); +export const useTokenSetter = () => useContext(TokenSetterContext); + + + +export default function AuthProvider({ children }) { + const [loggedIn, setLoggedIn] = useState( false ); + const [token, setToken] = useState( null ); + + const [cookies] = useCookies(); + + useEffect( _ => { + async function tryRefresh(csrf_refresh_token){ + console.log("CSRF Token", csrf_refresh_token) + if (csrf_refresh_token === undefined){ + return false; + } + + let new_access_token = await refresh(csrf_refresh_token); + if (!new_access_token){ + console.error("Failed to refresh access token.") + return false; + } + + setToken(new_access_token); + setLoggedIn(true); + } + tryRefresh(cookies.csrf_refresh_token); + }, []); + + return ( + + + + + {children} + + + + + ); +}; \ No newline at end of file diff --git a/src/components/AuthProvider/AuthProvider.md b/src/components/AuthProvider/AuthProvider.md new file mode 100644 index 0000000..aad5bee --- /dev/null +++ b/src/components/AuthProvider/AuthProvider.md @@ -0,0 +1,14 @@ +AuthProvider + +Description + +--- + +```jsx +import AuthProvider from "./AuthProvider"; + + +``` +```jsx static + +``` \ No newline at end of file diff --git a/src/components/AuthProvider/index.js b/src/components/AuthProvider/index.js new file mode 100644 index 0000000..165e516 --- /dev/null +++ b/src/components/AuthProvider/index.js @@ -0,0 +1 @@ +export { default, useLogin, useLoginSetter, useToken, useTokenSetter } from "./AuthProvider"; \ No newline at end of file diff --git a/src/components/LoginForm/LoginForm.js b/src/components/LoginForm/LoginForm.js new file mode 100644 index 0000000..5eb2392 --- /dev/null +++ b/src/components/LoginForm/LoginForm.js @@ -0,0 +1,146 @@ +import React, { useState } from "react"; +import { Box, Paper, TextField, Button, Avatar, Typography, InputAdornment, IconButton, useTheme, makeStyles } from "@material-ui/core"; +import Visibility from '@material-ui/icons/Visibility'; +import VisibilityOff from '@material-ui/icons/VisibilityOff'; +import { Redirect } from "react-router-dom"; +import { Alert } from '@material-ui/lab'; +import { useLogin, useLoginSetter, useTokenSetter } from "../AuthProvider/"; +import { login } from "../../auth/"; + +export default function LoginForm() { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(false); + const [showPassword, setShowPassword] = useState(false); + + const handleUsernameChange = (event) => setUsername(event.target.value); + const handlePasswordChange = (event) => setPassword(event.target.value); + + const setLogin = useLoginSetter(); + const setToken = useTokenSetter(); + const handleSubmit = async (event) => { + event.preventDefault(); + let access_token = await login(username, password); + + if (!access_token){ + setError(true); + return false; + } + + setLogin(true); + setToken(access_token); + return true; + } + + const theme = useTheme(); + const useStyles = makeStyles({ + "box_root": { + background: `linear-gradient(120deg, ${theme.palette.secondary.main}35 0%, ${theme.palette.primary.main}15 100%)`, + width: "100%", + height: "100vh", + display: "flex", + justifyContent: "center", + alignItems: "center" + }, + "avatar_root": { + padding: theme.spacing(1), + width: theme.spacing(10), + height: theme.spacing(10) + }, + "alert_root": { + marginTop: theme.spacing(2) + }, + "paper_root": { + minWidth: theme.breakpoints.values.sm/2, + padding: theme.spacing(3), + display: "flex", + flexDirection: "column", + justifyContent: "center", + alignItems: "center", + }, + "button_root": { + marginTop: theme.spacing(2) + } + }) + const classes = useStyles(); + + const isLoggedIn = useLogin(); + if (isLoggedIn) { + return + } + + const LoginErrorAlert = _ => { + return ( + + Username or password is incorrect. + + ); + } + + const ViewPasswordToggle = _ => { + return ( + + setShowPassword(!showPassword) } + onMouseDown={ (event) => event.preventDefault() } + > + { showPassword ? : } + + + ); + } + + return ( + +
+ + + + Sign In + + { error && } + + + }} + /> + + +
+
+ ); +}; \ No newline at end of file diff --git a/src/components/LoginForm/LoginForm.md b/src/components/LoginForm/LoginForm.md new file mode 100644 index 0000000..7e0553c --- /dev/null +++ b/src/components/LoginForm/LoginForm.md @@ -0,0 +1,11 @@ +The LoginForm acts as the only public facing page for the webqueue2. If any part of the app is access without access tokens, the user will be redirected here. It takes a username and password, attempts to login an, if successful, sets access tokens and redirects users to webqueue2. + +--- +```jsx +import LoginForm from "./LoginForm"; + + +``` +```jsx static + +``` \ No newline at end of file diff --git a/src/components/LoginForm/index.js b/src/components/LoginForm/index.js new file mode 100644 index 0000000..3789b92 --- /dev/null +++ b/src/components/LoginForm/index.js @@ -0,0 +1 @@ +export { default } from "./LoginForm"; \ No newline at end of file diff --git a/src/components/PrivateRoute/PrivateRoute.js b/src/components/PrivateRoute/PrivateRoute.js new file mode 100644 index 0000000..9a7ebad --- /dev/null +++ b/src/components/PrivateRoute/PrivateRoute.js @@ -0,0 +1,23 @@ +import React from 'react'; +import PropTypes from "prop-types"; +import { Route, Redirect } from 'react-router-dom'; +import { useLogin } from "../AuthProvider/"; + +export default function PrivateRoute({ children, ...rest }) { + const isLoggedIn = useLogin(); + + return ( + + { + isLoggedIn + ? children + : + } + + ); +}; + +PrivateRoute.propTypes = { + /** The route's path. */ + "path": PropTypes.string.isRequired +}; \ No newline at end of file diff --git a/src/components/PrivateRoute/PrivateRoute.md b/src/components/PrivateRoute/PrivateRoute.md new file mode 100644 index 0000000..7f0d5c0 --- /dev/null +++ b/src/components/PrivateRoute/PrivateRoute.md @@ -0,0 +1 @@ +The PrivateRoute wraps [React Router](https://reactrouter.com/)'s [Route component](https://reactrouter.com/web/api/Route) and checks for authentication using [AuthProvider](#/Components/AuthProvider). If authentication is valid, the children of the PrivateRoute are rendered. Otherwise, the user is redirected to the login page. \ No newline at end of file diff --git a/src/components/PrivateRoute/index.js b/src/components/PrivateRoute/index.js new file mode 100644 index 0000000..4c9765d --- /dev/null +++ b/src/components/PrivateRoute/index.js @@ -0,0 +1 @@ +export { default } from "./PrivateRoute"; \ No newline at end of file diff --git a/src/index.js b/src/index.js index 578b96b..799ad12 100644 --- a/src/index.js +++ b/src/index.js @@ -5,6 +5,8 @@ import * as serviceWorker from './serviceWorker'; import { createBrowserHistory } from 'history'; import { CssBaseline } from '@material-ui/core'; import { BrowserRouter as Router } from 'react-router-dom'; +import { CookiesProvider } from "react-cookie"; +import AuthProvider from "./components/AuthProvider/"; export const history = createBrowserHistory({ basename: process.env.PUBLIC_URL @@ -13,9 +15,13 @@ export const history = createBrowserHistory({ ReactDOM.render( - - - + + + + + + + , document.getElementById('root') );