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/README.md b/README.md index 36e6329..0b31b4f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # webqueue2 A re-write of Purdue ECN's webqueue -![UI Snapshot](./docs/UI%20Snapshots/UI-Snapshot%202020-09-22%20at%201.48.58%20PM.png) +![UI Snapshot](./docs/UI%20Snapshots/UI-Snapshot%202020-12-03%20at%208.10.32%20PM.png) ## Stay Up To Date See what's being worked on with [the webqueue2 Project](https://github.itap.purdue.edu/ECN/webqueue2/projects/). diff --git a/api/ECNQueue.py b/api/ECNQueue.py index 6d73768..2cfcf98 100644 --- a/api/ECNQueue.py +++ b/api/ECNQueue.py @@ -228,8 +228,22 @@ def __parseHeaders(self) -> list: message = email.message_from_string(headerString) headers = [] + dateHeaders=[ + "QStatus-Updated-Time", + "Status-Updated-Time", + "Edited-Time", + "QTime-Updated-Time", + "Merged-Time", + "Time-Updated-Time", + "Replied-Time", + "Assigned-To-Updated-Time", + "QAssigned-To-Updated-Time", + "Date", + "Sent" + ] + for key in message.keys(): - headers.append({"type": key, "content": message[key]}) + headers.append({"type": key, "content": self.__getFormattedDate(message[key]) if key in dateHeaders else message[key]}) return headers @@ -1331,4 +1345,4 @@ def loadQueues() -> list: for queue in getValidQueues(): queues.append(Queue(queue)) - return queues \ No newline at end of file + return queues 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 cc3692a..59e3e3c 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -16,4 +16,4 @@ PyJWT == 1.* # API Documentation mkdocs mkdocs-material -mkautodoc +mkautodoc \ No newline at end of file diff --git a/docs/UI Snapshots/UI-Snapshot 2020-11-13 at 1.48.58 PM.png b/docs/UI Snapshots/UI-Snapshot 2020-11-13 at 1.48.58 PM.png new file mode 100644 index 0000000..eb6d9e6 Binary files /dev/null and b/docs/UI Snapshots/UI-Snapshot 2020-11-13 at 1.48.58 PM.png differ diff --git a/docs/UI Snapshots/UI-Snapshot 2020-11-23 at 8.57.36 AM.png b/docs/UI Snapshots/UI-Snapshot 2020-11-23 at 8.57.36 AM.png new file mode 100644 index 0000000..cb06397 Binary files /dev/null and b/docs/UI Snapshots/UI-Snapshot 2020-11-23 at 8.57.36 AM.png differ diff --git a/docs/UI Snapshots/UI-Snapshot 2020-12-03 at 8.10.32 PM.png b/docs/UI Snapshots/UI-Snapshot 2020-12-03 at 8.10.32 PM.png new file mode 100644 index 0000000..aa66e5d Binary files /dev/null and b/docs/UI Snapshots/UI-Snapshot 2020-12-03 at 8.10.32 PM.png differ diff --git a/package-lock.json b/package-lock.json index 0910693..b918c04 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", @@ -9040,6 +9054,11 @@ "object.assign": "^4.1.0" } }, + "jwt-decode": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", + "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" + }, "killable": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz", @@ -12065,6 +12084,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 +15791,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 bd6ff48..8b496df 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,15 @@ "@testing-library/user-event": "^7.2.1", "clsx": "^1.1.1", "history": "^5.0.0", + "jwt-decode": "^3.1.2", "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..75c7c60 --- /dev/null +++ b/src/components/AppView/AppView.js @@ -0,0 +1,143 @@ +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 [queueSelectorOpen, setQueueSelectorOpen] = useState(false); + + const access_token = useToken(); + + useEffect( _ => { + async function getQueues(){ + if (access_token === null){ + return undefined + } + + if (queueSelectorOpen){ + return undefined + } + + 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, queueSelectorOpen]); + + useEffect( _ => { + let tempItems = []; + for (let queue of queues){ + tempItems = tempItems.concat(queue.items); + } + setItems(tempItems); + }, [queues]); + + 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( + + + + + + + + + + {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..ae0effe --- /dev/null +++ b/src/components/AuthProvider/AuthProvider.js @@ -0,0 +1,78 @@ +import React, { useState, createContext, useContext, useEffect } from "react"; +import { useCookies } from "react-cookie"; +import { refresh } from "../../auth/"; +import decodeJWT from "jwt-decode"; + + + +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(["csrf_refresh_token"]); + + async function tryRefresh(csrf_refresh_token){ + if (csrf_refresh_token === undefined){ + return false; + } + + const 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); + } + + // Attempt to refresh token on page load + useEffect( _ => { + (async () => { + await tryRefresh(cookies.csrf_refresh_token); + })(); + }, [cookies]); + + // Auto update token + useEffect( () => { + if (token === null) { + return undefined; + } + + // 5 second buffer for access token refresh + const refersh_buffer_time = 5000; + const access_token_expiration_claim = decodeJWT(token).exp + const access_token_expiration_time = new Date(0).setUTCSeconds(access_token_expiration_claim); + const miliseconds_to_access_token_expiration = (access_token_expiration_time - Date.now()) + + const timer = setTimeout( async () => { + await tryRefresh(cookies.csrf_refresh_token); + }, miliseconds_to_access_token_expiration - refersh_buffer_time); + + return () => clearTimeout(timer); + }, [token, cookies]); + + 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/ItemBodyView/ItemBodyView.js b/src/components/ItemBodyView/ItemBodyView.js index 4e5edce..08a57f9 100644 --- a/src/components/ItemBodyView/ItemBodyView.js +++ b/src/components/ItemBodyView/ItemBodyView.js @@ -13,8 +13,9 @@ export default function ItemBodyView({ item }) { const useStyles = makeStyles(() => ({ "Timeline-root": { - paddingLeft: "0", - paddingRight: "0", + padding: "0", + marginTop: "0", + marginBottom: "0", }, "TimelineContent-root": { paddingRight: "0", diff --git a/src/components/ItemHeaderView/ItemHeaderView.js b/src/components/ItemHeaderView/ItemHeaderView.js new file mode 100644 index 0000000..798a3fb --- /dev/null +++ b/src/components/ItemHeaderView/ItemHeaderView.js @@ -0,0 +1,93 @@ +import React, { useMemo } from "react"; +import PropTypes from "prop-types"; +import { useTable, useFlexLayout, useFilters } from "react-table"; +import { TableContainer, Table, TableHead, TableRow, TableCell, TableBody, TextField, useTheme, makeStyles } from "@material-ui/core"; + +export default function ItemHeaderView({ data }) { + + const theme = useTheme(); + const useStyles = makeStyles({ + HeaderCell_root: { + paddingBottom: theme.spacing(2), + borderBottomWidth: 0 + }, + ContentCell_root: { + wordBreak: "break-word" + }, + bandedRows: { + '&:nth-of-type(even)': { + backgroundColor: theme.palette.type === 'light' ? theme.palette.grey[50] : theme.palette.grey[700], + } + } + }); + const classes = useStyles(); + + const columns = useMemo(() => [ + { Header: 'Type', accessor: 'type', Cell: ({ value }) => {value} , width: 1 }, + { Header: 'Content', accessor: 'content', width: 2 } + ], []); + + const defaultColumn = { + Filter: ({ column: { Header, setFilter } }) => ( + setFilter(event.target.value) } + type="search" + size="small" + variant="outlined" + color="secondary" + fullWidth + /> + ) + } + + const tableInstance = useTable({ columns, data, defaultColumn }, useFlexLayout, useFilters); + const { + getTableProps, + getTableBodyProps, + headerGroups, + rows, + prepareRow + } = tableInstance; + + return ( + + + + {headerGroups.map(headerGroup => ( + + {headerGroup.headers.map(column => ( + + {column.render('Filter')} + + ))} + + ))} + + + {rows.map( (row) => { + prepareRow(row); + return ( + + {row.cells.map( (cell) => ( + + {cell.render("Cell")} + + ))} + + ); + })} + +
+
+ ); +}; + +ItemHeaderView.propTypes = { + /** An array of object containing header type and content. */ + "data": PropTypes.array +}; + +ItemHeaderView.defaultProps = { + "data": [] +} \ No newline at end of file diff --git a/src/components/ItemHeaderView/ItemHeaderView.md b/src/components/ItemHeaderView/ItemHeaderView.md new file mode 100644 index 0000000..e69de29 diff --git a/src/components/ItemHeaderView/index.js b/src/components/ItemHeaderView/index.js new file mode 100644 index 0000000..f47b29b --- /dev/null +++ b/src/components/ItemHeaderView/index.js @@ -0,0 +1 @@ +export { default } from "./ItemHeaderView"; \ No newline at end of file diff --git a/src/components/ItemTable/ItemTable.js b/src/components/ItemTable/ItemTable.js index f05fc5f..6677f28 100644 --- a/src/components/ItemTable/ItemTable.js +++ b/src/components/ItemTable/ItemTable.js @@ -9,8 +9,8 @@ import { ArrowDownward, ArrowUpward } from "@material-ui/icons"; import ItemTableCell from "../ItemTableCell"; import LastUpdatedCell from "../LastUpdatedCell/"; -export default function ItemTable({ data }) { - const [selectedRow, setSelecetedRow] = useState({ queue: null, number: null }); +export default function ItemTable({ data, rowCanBeSelected }) { + const [selectedRow, setSelectedRow] = useState({ queue: null, number: null}); const theme = useTheme(); const useStyles = makeStyles({ @@ -23,9 +23,7 @@ export default function ItemTable({ data }) { opacity: 0.2, }, rowSelected: { - "&$selected, &$selected:hover": { - backgroundColor: theme.palette.primary, - }, + backgroundColor: theme.palette.type === 'light' ? theme.palette.primary[100] : theme.palette.primary[600], }, bandedRows: { '&:nth-of-type(even)': { @@ -59,6 +57,8 @@ export default function ItemTable({ data }) { { columns, data, + autoResetSortBy: false, + autoResetFilters: false, defaultColumn: { Filter: ({ column: { Header, setFilter } }) => { return ( @@ -70,7 +70,7 @@ export default function ItemTable({ data }) { }, }, }, - useFilters, useFlexLayout, useSortBy, + useFilters, useFlexLayout, useSortBy, ); const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow, } = tableInstance; @@ -134,10 +134,12 @@ export default function ItemTable({ data }) { { history.push(`/${row.original.queue}/${row.original.number}`); - setSelecetedRow({ queue: row.original.queue, number: row.original.number }); + setSelectedRow({ queue: row.original.queue, number: row.original.number }); }} - classes={{ root: isSelected ? classes.rowSelected : classes.bandedRows }} - selected={isSelected} + // 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. + // Overridding the root class is a workaround. + classes={{ root: (isSelected && rowCanBeSelected) ? classes.rowSelected : classes.bandedRows }} {...row.getRowProps()} > {row.cells.map(cell => ( @@ -176,9 +178,14 @@ export default function ItemTable({ data }) { ItemTable.propTypes = { /** Array of items from all active queues to display in table. */ - "items": PropTypes.array + "items": PropTypes.array, + /** State variable indicating if rows can be selected. When false, all rows are deselected. */ + "rowCanBeSelected": PropTypes.bool }; ItemTable.defaultProps = { - "items": [] + /** The items to display in the table. */ + "items": [], + /** A state variable determining whether a row can be selected or not. */ + "rowCanBeSelected": true }; diff --git a/src/components/ItemView/ItemView.js b/src/components/ItemView/ItemView.js index 5ec157a..8bd7eda 100644 --- a/src/components/ItemView/ItemView.js +++ b/src/components/ItemView/ItemView.js @@ -1,10 +1,15 @@ -import React from 'react'; +import React, { useState } from 'react'; import PropTypes from "prop-types"; -import { Paper, makeStyles, useTheme } from '@material-ui/core'; +import { Paper, AppBar, Tab, makeStyles, useTheme } from '@material-ui/core'; +// Import these tab components from @material-ui/lab instead of @material-ui/core for automatic a11y props +// See: https://material-ui.com/components/tabs/#experimental-api +import { TabContext, TabList, TabPanel } from '@material-ui/lab'; import ItemMetadataView from "../ItemMetadataView/" import ItemBodyView from "../ItemBodyView"; +import ItemHeaderView from "../ItemHeaderView"; export default function ItemView({ activeItem }){ + const [activeTab, setActiveTab] = useState('Conversation'); const theme = useTheme(); const useStyles = makeStyles({ @@ -12,15 +17,35 @@ export default function ItemView({ activeItem }){ paddingTop: theme.spacing(1), paddingLeft: theme.spacing(2), paddingRight: theme.spacing(2), - border: "none" + border: "none", + }, + "tabPanelPadding": { + padding: `${theme.spacing(2)}px ${theme.spacing(2)}px` } }); const classes = useStyles(); -return( + const handleTabChange = (event, newValue) => { + setActiveTab(newValue); + }; + + return( - + + + + + + + + + + + + + + ); }; 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 index f4d8c4a..aa1a816 100644 --- a/src/components/QueueSelector/QueueSelector.js +++ b/src/components/QueueSelector/QueueSelector.js @@ -1,57 +1,176 @@ -import React from "react"; +import React, { useState, useEffect } from "react"; import PropTypes from "prop-types"; -import { TextField, Checkbox} from "@material-ui/core"; +import { TextField, Checkbox, InputAdornment, Box, useTheme } from "@material-ui/core"; import { Autocomplete } from "@material-ui/lab"; import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'; import CheckBoxIcon from '@material-ui/icons/CheckBox'; +import CircularProgress from '@material-ui/core/CircularProgress'; +import { useCookies } from "react-cookie"; +import { useToken } from "../AuthProvider/"; + +/** + * Get queue names and number of items. + * @param {String} access_token A valid API access token. + * @returns Array of objects containing queue names and item counts. + */ +const getQueueCounts = async (access_token) => { + if (access_token === null){ + return undefined + } + + 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(); + + return queueCountJson; +}; + +export default function QueueSelector({ open, setOpen, value, setValue }) { + const [queueCounts, setQueueCounts] = useState([]); + const [isFirstRender, setIsFirstRender] = useState(true); + const access_token = useToken(); + const loading = open && queueCounts.length === 0; + + const [cookies, setCookie] = useCookies(["active-queues"]); + const activeQueues = cookies['active-queues'] !== undefined ? cookies['active-queues'].split(',') : []; + + const theme = useTheme(); + + // Prepopulate Active Queues from Cookies + useEffect( _ => { + if (access_token === null){ + return undefined; + } + + if (isFirstRender) { + ( async _ => { + // Get queue counts + let queueCountsJson = await getQueueCounts(access_token); + + // Find queue count info for queue names in active queues + let activeQueuesInfo = activeQueues.map((queueName) => ( + queueCountsJson.find( ({ name }) => queueName === name ) + )); + + // Filter undefined values + activeQueuesInfo = activeQueuesInfo.filter( (entry) => entry !== undefined); + + setValue(activeQueuesInfo); + setIsFirstRender(false); + })(); + } + }, []); + + // Get queue counts if QueueSelector is open + useEffect( _ => { + (async _ => { + if (loading) { + let queueCountsJson = await getQueueCounts(access_token); + setQueueCounts(queueCountsJson); + } + })() + }, [loading, access_token]); + + // Delete queue counts if QueueSelector is closed + useEffect(() => { + if (!open) { + setQueueCounts([]); + } + }, [open]); + + const handleChange = (event, newValue) => { + setValue(newValue) + + // Set active-queues cookie to csv of selected queue names + const activeQueueOptions = { + path: "/", + expires: (_ => { + let expiration_date = new Date(); + expiration_date.setDate(expiration_date.getDate() + 365); + return expiration_date; + })() + }; + const activeQueues = newValue.map( (value) => value.name).join(','); + setCookie("active-queues", activeQueues, activeQueueOptions); + }; + + // Function to render checkboxes in dropdown + // See `renderOptions` prop at https://material-ui.com/api/autocomplete/#props + const optionRenderer = (option, { selected }) => ( + <> + } + checkedIcon={} + style={{ marginRight: 8 }} + checked={selected} + /> + {`${option.name} (${option.number_of_items})`} + + ); -export default function QueueSelector({ queues, selectedQueues, setSelectedQueues }) { return( - { - setSelectedQueues(newValue) - }} - renderInput={(params) => { - return( + // Box is used for margin because Autocomplete CSS overrides don't work as expected. + + ( - ); - }} - getOptionLabel={(option) => `${option.name} (${option.number_of_items})`} - renderOption={(option, { selected }) => ( - <> - } - checkedIcon={} - style={{ marginRight: 8 }} - checked={selected} + variant="outlined" + placeholder={value.length === 0 ? "Click or type to select queues." : ""} + autoFocus + // The MUI Autocomplete component uses the InputProps.startAdornment to store chips fpr multi-selection. + // Using InputProps.startAdornment directly will override those chips. Code below is a workaround. + // See: https://github.com/mui-org/material-ui/issues/19479 + InputProps={{ + ...params.InputProps, + startAdornment: ( + <> + + Active Queues: + + {params.InputProps.startAdornment} + + ), + endAdornment: ( + <> + {loading ? : null} + {params.InputProps.endAdornment} + + ) + }} /> - {`${option.name} (${option.number_of_items})`} - - )} - getOptionSelected={ (option, value) => option.name === value.name } - disableCloseOnSelect - autoComplete - disableListWrap - openOnFocus - fullWidth - multiple - /> + )} + options={queueCounts} + value={value} + onChange={handleChange} + getOptionLabel={(option) => `${option.name} (${option.number_of_items})`} + renderOption={optionRenderer} + getOptionSelected={ (option, value) => option.name === value.name } + size="small" + open={open} + onOpen={_ => setOpen(true)} + onClose={_ => setOpen(false)} + loading={true} + disableCloseOnSelect + disableListWrap + fullWidth + multiple + autoHighlight + /> + ); }; QueueSelector.propTypes = { - /** An array of objects with keys of name and number of items for each queue. */ - "queues": PropTypes.array, + /** State variable to manage open status. */ + "open": PropTypes.bool.isRequired, + /** Function to update state variable that manages open status. */ + "setOpen": PropTypes.func.isRequired, /** State variable to manage selected queues. */ - "selectedQueues": PropTypes.array.isRequired, + "value": PropTypes.array.isRequired, /** Function to update state variable that manages selected queues. */ - "setSelectedQueues": PropTypes.func.isRequired -}; - -QueueSelector.defaultProps = { - "queues": [] + "setValue": PropTypes.func.isRequired, }; \ 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') );