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/Dev Environment Setup Guide.md b/Dev Environment Setup Guide.md index e80c78b..03ed436 100644 --- a/Dev Environment Setup Guide.md +++ b/Dev Environment Setup Guide.md @@ -252,4 +252,5 @@ All of the tools in this project are accessible as an npm task so you can intera | `kill:api` | Kills the runaway API process(es). | | `venv:create` | This will create a virtual environment in `/api/venv` and install requirements from `/api/requirements.txt`. | | `venv:delete` | This will delete the folder `/api/venv`. | -| `venv:reset` | This will run `venv:delete` then `venv:create`. | \ No newline at end of file +| `venv:reset` | This will run `venv:delete` then `venv:create`. | +| `venv:freeze` | Regenerates the API requirements.txt file and mitigates [this pip bug](https://github.com/pypa/pip/issues/4022). | diff --git a/api/ECNQueue.py b/api/ECNQueue.py index a8e1305..6d73768 100644 --- a/api/ECNQueue.py +++ b/api/ECNQueue.py @@ -1190,7 +1190,7 @@ def __getFormattedDate(self, date: str) -> str: try: # This date is never meant to be used. The default attribute is just to set timezone. parsedDate = parse(date, default=datetime.datetime( - 1970, 0, 1, tzinfo=tz.gettz('EDT'))) + 1970, 1, 1, tzinfo=tz.gettz('EDT'))) except: return "" 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..71b539c 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", @@ -32,6 +33,7 @@ "venv:create": "python3 utils/venv-manager.py create", "venv:delete": "python3 utils/venv-manager.py delete", "venv:reset": "python3 utils/venv-manager.py reset", + "venv:freeze": "cd api/ && venv/bin/pip freeze | grep -v 'pkg-resources' > requirements.txt", "test": "react-scripts test", "eject": "react-scripts eject" }, diff --git a/src/App.js b/src/App.js index a7cfa5a..c410707 100644 --- a/src/App.js +++ b/src/App.js @@ -1,110 +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 { 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([]); - - useEffect( _ => { - async function getQueues(){ - const apiResponse = await fetch("/api/ce"); - const queueJson = await apiResponse.json(); - setQueues(queueJson); - } - getQueues(); - }, []); - - useEffect( _ => { - let tempItems = []; - for (let queue of queues){ - tempItems = tempItems.concat(queue.items); - } - setItems(tempItems); - }, [queues]); 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 ( - - - - - - - - - {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/ItemTable/ItemTable.js b/src/components/ItemTable/ItemTable.js index b7b85d5..701aabc 100644 --- a/src/components/ItemTable/ItemTable.js +++ b/src/components/ItemTable/ItemTable.js @@ -7,6 +7,7 @@ import RelativeTime from "react-relative-time"; import ItemTableFilter from "../ItemTableFilter/" import { ArrowDownward, ArrowUpward } from "@material-ui/icons"; + export default function ItemTable({ data, rowCanBeSelected }) { const theme = useTheme(); @@ -68,11 +69,8 @@ export default function ItemTable({ data, rowCanBeSelected }) { }, useFilters, useFlexLayout, useSortBy, ); - const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow, } = tableInstance; - const [selectedRow, setSelecetedRow] = useState({ queue: null, number: null }); - return ( @@ -129,7 +127,7 @@ export default function ItemTable({ data, rowCanBeSelected }) { { history.push(`/${row.original.queue}/${row.original.number}`); - setSelecetedRow({ queue: row.original.queue, number: row.original.number }) + setSelecetedRow({ queue: row.original.queue, number: row.original.number }); }} // This functionality should be achieved by using the selected prop and // overriding the selected class but this applied the secondary color at 0.08% opacity. @@ -163,4 +161,4 @@ ItemTable.propTypes = { ItemTable.defaultProps = { "items": [], "rowCanBeSelected": true -}; \ No newline at end of file +}; diff --git a/src/components/ItemTableFilter/ItemTableFilter.js b/src/components/ItemTableFilter/ItemTableFilter.js index 68f9d56..b5a2fae 100644 --- a/src/components/ItemTableFilter/ItemTableFilter.js +++ b/src/components/ItemTableFilter/ItemTableFilter.js @@ -1,21 +1,33 @@ -import React from 'react'; +import React, { useState } from 'react'; import PropTypes from 'prop-types'; -import { TextField } from "@material-ui/core"; +import { makeStyles, TextField } from "@material-ui/core"; export default function ItemTableFilter({ label, onChange }) { + const useStyles = makeStyles({ + labelRoot: { + overflow: "hidden" + }, + labelFocused: { + overflow: "visible" + }, + }); + const classes = useStyles(); + + const [isFocused, setIsFocused] = useState(false); + return ( - <> setIsFocused(true)} + onBlur={() => setIsFocused(false)} + className={ isFocused ? classes.labelFocused : classes.labelRoot } color="secondary" type="search" size="small" variant="outlined" - fullWidth /> - ); }; 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/components/QueueSelector/QueueSelector.js b/src/components/QueueSelector/QueueSelector.js new file mode 100644 index 0000000..f4d8c4a --- /dev/null +++ b/src/components/QueueSelector/QueueSelector.js @@ -0,0 +1,57 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { TextField, Checkbox} from "@material-ui/core"; +import { Autocomplete } from "@material-ui/lab"; +import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'; +import CheckBoxIcon from '@material-ui/icons/CheckBox'; + +export default function QueueSelector({ queues, selectedQueues, setSelectedQueues }) { + return( + { + setSelectedQueues(newValue) + }} + renderInput={(params) => { + return( + + ); + }} + getOptionLabel={(option) => `${option.name} (${option.number_of_items})`} + renderOption={(option, { selected }) => ( + <> + } + checkedIcon={} + style={{ marginRight: 8 }} + checked={selected} + /> + {`${option.name} (${option.number_of_items})`} + + )} + getOptionSelected={ (option, value) => option.name === value.name } + disableCloseOnSelect + autoComplete + disableListWrap + openOnFocus + fullWidth + multiple + /> + ); +}; + +QueueSelector.propTypes = { + /** An array of objects with keys of name and number of items for each queue. */ + "queues": PropTypes.array, + /** State variable to manage selected queues. */ + "selectedQueues": PropTypes.array.isRequired, + /** Function to update state variable that manages selected queues. */ + "setSelectedQueues": PropTypes.func.isRequired +}; + +QueueSelector.defaultProps = { + "queues": [] +}; \ No newline at end of file diff --git a/src/components/QueueSelector/QueueSelector.md b/src/components/QueueSelector/QueueSelector.md new file mode 100644 index 0000000..ea505eb --- /dev/null +++ b/src/components/QueueSelector/QueueSelector.md @@ -0,0 +1,38 @@ +Allows the selection, removal and viewing of active queues. Its extends the [MUI Autocomplete component](https://material-ui.com/components/autocomplete/). + +--- +```jsx +import React, { useState } from "react"; +import { Paper, makeStyles } from "@material-ui/core"; +import QueueSelector from "./QueueSelector"; + +const [selectedQueues, setSelectedQueues] = useState([]); +const queues = [ + { + 'name': 'bidc', + 'number_of_items': 5 + }, + { + 'name': 'epics', + 'number_of_items': 6 + }, + { + 'name': 'wang', + 'number_of_items': 13 + } +]; + +const useStyles = makeStyles({ + root: { + padding: "16px", + } +}); +const classes = useStyles(); + + + + +``` +```jsx static + +``` \ No newline at end of file diff --git a/src/components/QueueSelector/index.js b/src/components/QueueSelector/index.js new file mode 100644 index 0000000..4686319 --- /dev/null +++ b/src/components/QueueSelector/index.js @@ -0,0 +1 @@ +export { default } from "./QueueSelector"; \ 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') );