From 62bd416385ee85d008faae3ad3962d2d7632af03 Mon Sep 17 00:00:00 2001 From: Yaxue Guo <37635744+yaxue1123@users.noreply.github.com> Date: Fri, 13 Jan 2023 15:31:57 -0500 Subject: [PATCH] Feature/session timeout modal (#240) * #136: added SessionTimeout component to app * #136: added two popped-up modals before session time-out * #136: added count down timer to the popup modals * #136: added auto refresh token feature * #136: set time intervals in portal data json file * #136: updated auto-refresh token to only for project-specific tokens * #136: updated CM calls based on CM API update * #136: replaced session timeout period with actual time * #136: updated user status check via /whoami response * #136: added length validation for boot script & sliver name * #136: updated user status local storage" * #136: added FABRIC CILogon stylesheet * #136: auto change FABRIC Logo between different tiers --- src/App.js | 72 ++++++++++--- src/components/Experiment/Slices.jsx | 23 +--- src/components/Header.jsx | 33 +++--- src/components/Modals/SessionTimeoutModal.jsx | 100 ++++++++++++++++++ src/components/Slice/SlicesTable.jsx | 2 +- src/components/SliceViewer/SideLinks.jsx | 1 + src/components/SliceViewer/SideNodes.jsx | 3 +- src/pages/SliceViewer.jsx | 49 ++------- src/services/httpService.js | 32 ++++-- src/services/portalData.json | 3 + src/styles/FABRIC_CILogon.css | 73 +++++++++++++ src/utils/clearLocalStorage.js | 15 +++ src/utils/manageTokens.js | 20 +++- src/utils/sliceValidator.js | 33 ++++-- 14 files changed, 351 insertions(+), 108 deletions(-) create mode 100644 src/components/Modals/SessionTimeoutModal.jsx create mode 100644 src/styles/FABRIC_CILogon.css create mode 100644 src/utils/clearLocalStorage.js diff --git a/src/App.js b/src/App.js index 1e6ea594..8772b003 100644 --- a/src/App.js +++ b/src/App.js @@ -3,6 +3,7 @@ import { BrowserRouter as Router, Switch, Route } from "react-router-dom"; import { getWhoAmI } from "./services/peopleService.js"; import { getCurrentUser } from "./services/peopleService.js"; import { getActiveMaintenanceNotice } from "./services/announcementService.js"; +import { default as portalData } from "./services/portalData.json"; import Home from "./pages/Home"; import Resources from "./pages/Resources"; import Projects from "./pages/Projects"; @@ -21,6 +22,7 @@ import Help from "./pages/Help"; import Header from "./components/Header"; import Banner from "./components/common/Banner"; import Footer from "./components/Footer"; +import SessionTimeoutModal from "./components/Modals/SessionTimeoutModal"; import { toast, ToastContainer } from "react-toastify"; import ProtectedRoute from "./components/common/ProtectedRoute"; import "./styles/App.scss"; @@ -29,6 +31,8 @@ class App extends React.Component { state = { userStatus: "", activeNotices: [], + showSessionTimeoutModal1: false, + showSessionTimeoutModal2: false, }; async componentDidMount() { @@ -42,20 +46,45 @@ class App extends React.Component { // if no user status info is stored, call UIS getWhoAmI. if (!localStorage.getItem("userStatus")) { - const { data } = await getWhoAmI(); - const user = data.results[0]; - if (user.enrolled) { - localStorage.setItem("userID", user.uuid); - localStorage.setItem("userStatus", "active"); - try { - const { data: res } = await getCurrentUser(); - localStorage.setItem("bastionLogin", res.results[0].bastion_login); - } catch (err) { - console.log("Failed to get current user's information."); + try { + const { data } = await getWhoAmI(); + const user = data.results[0]; + if (user.enrolled) { + localStorage.setItem("userID", user.uuid); + localStorage.setItem("userStatus", "active"); + try { + const { data: res } = await getCurrentUser(); + localStorage.setItem("bastionLogin", res.results[0].bastion_login); + // after user logs in for 3hr55min, pop up first session time-out modal + const sessionTimeoutInterval1 = setInterval(() => + this.setState({showSessionTimeoutModal1: true}) + , portalData["5minBeforeCookieExpires"]); + + // after user logs in for 3hr59min, pop up second session time-out modal + const sessionTimeoutInterval2 = setInterval(() => { + this.setState({ + showSessionTimeoutModal1: false, + showSessionTimeoutModal2: true, + }) + }, portalData["1minBeforeCookieExpires"]); + + localStorage.setItem("sessionTimeoutInterval1", sessionTimeoutInterval1); + localStorage.setItem("sessionTimeoutInterval2", sessionTimeoutInterval2); + } catch (err) { + console.log("Failed to get current user's information."); + } + } + } catch (err) { + const errors = err.response.data.errors; + + if (errors && errors[0].details.includes("Login required")) { + localStorage.setItem("userStatus", "unauthorized"); + localStorage.removeItem("userID"); } - } else { - // situation 2: logged in, but not self signup, unauthenticated - localStorage.setItem("userStatus", "inactive"); + + if (errors && errors[0].details.includes("Enrollment required")) { + localStorage.setItem("userStatus", "inactive"); + } } } @@ -63,6 +92,7 @@ class App extends React.Component { } render() { + const { showSessionTimeoutModal1, showSessionTimeoutModal2 } = this.state; return (
@@ -75,6 +105,20 @@ class App extends React.Component { /> ) } + { + showSessionTimeoutModal1 && + + } + { + showSessionTimeoutModal2 && + + } @@ -85,7 +129,7 @@ class App extends React.Component { - + diff --git a/src/components/Experiment/Slices.jsx b/src/components/Experiment/Slices.jsx index deb2dd13..798c15c5 100644 --- a/src/components/Experiment/Slices.jsx +++ b/src/components/Experiment/Slices.jsx @@ -6,7 +6,7 @@ import SearchBoxWithDropdown from "../../components/common/SearchBoxWithDropdown import SlicesTable from "../Slice/SlicesTable"; import SpinnerWithText from "../../components/common/SpinnerWithText"; import { getProjects } from "../../services/projectService.js"; -import { autoCreateTokens, autoRefreshTokens } from "../../utils/manageTokens"; +import { autoCreateTokens } from "../../utils/manageTokens"; import { getSlices } from "../../services/sliceService.js"; import { toast } from "react-toastify"; import paginate from "../../utils/paginate"; @@ -44,30 +44,13 @@ class Slices extends React.Component { this.setState({ hasProject: false, showSpinner: false }); } else { // call credential manager to generate tokens - // if nothing found in browser storage - if (!localStorage.getItem("idToken") || !localStorage.getItem("refreshToken")) { - autoCreateTokens(res.results[0].uuid).then(async () => { + autoCreateTokens("all").then(async () => { const { data: res } = await getSlices(); this.setState({ slices: res.data, showSpinner: false }); }); - } else { - // the token has been stored in the browser and is ready to be used. - try { - const { data: res } = await getSlices(); - this.setState({ slices: res.data, showSpinner: false }); - } catch (err) { - this.setState({ showSpinner: false }); - toast.error("Failed to load slices. Please re-login and try."); - if (err.response.status === 401) { - // 401 Error: Provided token is not valid. - // refresh the token by calling credential manager refresh_token. - autoRefreshTokens(res.results[0].uuid); - } - } - } } } catch (err) { - toast.error("User's credential is expired. Please re-login."); + toast.error("Failed to get slices. Please re-login and try again."); } } diff --git a/src/components/Header.jsx b/src/components/Header.jsx index 228dbc7a..25982389 100644 --- a/src/components/Header.jsx +++ b/src/components/Header.jsx @@ -1,15 +1,14 @@ import React from "react"; import { NavLink } from "react-router-dom"; - import { toast } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; - import { default as portalData } from "../services/portalData.json"; - import { getCookieConsentValue } from "react-cookie-consent"; - import checkPortalType from "../utils/checkPortalType"; -import logo from "../imgs/fabric-brand.png"; +import productionLogo from "../imgs/fabric-brand.png"; +import alphaLogo from "../imgs/fabric-brand-alpha.png"; +import betaLogo from "../imgs/fabric-brand-beta.png"; +import clearLocalStorage from "../utils/clearLocalStorage"; class Header extends React.Component { state = { @@ -61,22 +60,28 @@ class Header extends React.Component { } } - handleLogout = () => { // revoke token generated by crendential manager. // TODO: Enable the revoke token when logout after removing credential manager from portal. // this.revokeToken(); - localStorage.removeItem("idToken"); - localStorage.removeItem("refreshToken"); - localStorage.removeItem("userID"); - localStorage.removeItem("bastionLogin"); - localStorage.removeItem("userStatus"); - localStorage.removeItem("sshKeyType"); - localStorage.removeItem("sliceDraft"); + clearLocalStorage(); // nginx handle logout url. window.location.href = "/logout"; } + getLogoSrc = () => { + const portal = checkPortalType(window.location.href); + if (portal === "production") { + return productionLogo; + } + if (portal === "beta") { + return betaLogo; + } + if (portal === "alpha") { + return alphaLogo; + } + } + render() { const navItems = this.props.userStatus !== "active" ? this.state.nonAuthNavItems : this.state.authNavItems; @@ -84,7 +89,7 @@ class Header extends React.Component {
diff --git a/src/components/SliceViewer/SideNodes.jsx b/src/components/SliceViewer/SideNodes.jsx index a5a6501a..2bc57d9f 100644 --- a/src/components/SliceViewer/SideNodes.jsx +++ b/src/components/SliceViewer/SideNodes.jsx @@ -148,7 +148,7 @@ class SideNodes extends React.Component { render() { const { selectedSite, nodeName, imageType, selectedImageRef, core, ram, disk, BootScript, nodeComponents } = this.state; - const validationResult = validator.validateNodeComponents(selectedSite, nodeName, this.props.nodes, core, ram, disk, nodeComponents); + const validationResult = validator.validateNodeComponents(selectedSite, nodeName, this.props.nodes, core, ram, disk, nodeComponents, BootScript); const renderTooltip = (id, content) => ( {content} @@ -237,6 +237,7 @@ class SideNodes extends React.Component { id="inputNodeName" value={nodeName} onChange={this.handleNameChange} + placeholder={"at least 2 characters..."} /> diff --git a/src/pages/SliceViewer.jsx b/src/pages/SliceViewer.jsx index 022eb043..3f1f90b2 100644 --- a/src/pages/SliceViewer.jsx +++ b/src/pages/SliceViewer.jsx @@ -6,8 +6,7 @@ import DeleteModal from "../components/common/DeleteModal"; import SpinnerWithText from "../components/common/SpinnerWithText"; import CountdownTimer from "../components/common/CountdownTimer"; import { Link } from "react-router-dom"; -import { autoCreateTokens, autoRefreshTokens } from "../utils/manageTokens"; -import { getProjects } from "../services/projectService.js"; +import { autoCreateTokens } from "../utils/manageTokens"; import { getSliceById, deleteSlice } from "../services/sliceService.js"; import sliceParser from "../services/parser/sliceParser.js"; import sliceErrorParser from "../services/parser/sliceErrorParser.js"; @@ -39,45 +38,17 @@ export default class SliceViewer extends Component { async componentDidMount() { this.setState({ showSliceSpinner: true }); - // call PR first to check if the user has project. try { - const { data: res } = await getProjects("myProjects", 0, 200); - if (res.results.length === 0) { - this.setState({ hasProject: false }); - } else { - // call credential manager to generate tokens - // if nothing found in browser storage - if (!localStorage.getItem("idToken") || !localStorage.getItem("refreshToken")) { - autoCreateTokens(res.results[0].uuid).then(async () => { - const { data: res } = await getSliceById(this.props.match.params.id); - this.setState({ - elements: sliceParser(res.data[0]["model"]), - slice: res.data[0], - errors: sliceErrorParser(res.data[0]["model"]), - showSliceSpinner: false - }); + // call credential manager to generate tokens + autoCreateTokens(this.props.match.params.project_id).then(async () => { + const { data: res } = await getSliceById(this.props.match.params.slice_id); + this.setState({ + elements: sliceParser(res.data[0]["model"]), + slice: res.data[0], + errors: sliceErrorParser(res.data[0]["model"]), + showSliceSpinner: false }); - } else { - // the token has been stored in the browser and is ready to be used. - try { - const { data: res } = await getSliceById(this.props.match.params.id); - this.setState({ - elements: sliceParser(res.data[0]["model"]), - slice: res.data[0], - errors: sliceErrorParser(res.data[0]["model"]), - showSliceSpinner: false - }); - } catch (err) { - this.setState({ showSliceSpinner: false }); - toast.error("Failed to load the slice. Please try again later."); - if (err.response.status === 401) { - // 401 Error: Provided token is not valid. - // refresh the token by calling credential manager refresh_token. - autoRefreshTokens(res.results[0].uuid); - } - } - } - } + }); } catch (err) { toast.error("User's credential is expired. Please re-login."); } diff --git a/src/services/httpService.js b/src/services/httpService.js index bd9aaa17..21691b0d 100644 --- a/src/services/httpService.js +++ b/src/services/httpService.js @@ -7,12 +7,22 @@ axios.defaults.withCredentials = true; axios.interceptors.response.use(null, (error) => { if (error.response && error.response.status === 401) { - // 1. the user has not logged in - // 2. or the auth cookie is expired + // 1. the user has not logged in (errors.details: "Login required: ...") + // 2. the user login but haven't enrolled yet (errors.details: "Enrollment required: ...") + // 3. or the auth cookie is expired const isCookieExpired = localStorage.getItem("userStatus", "active"); - // set status to unauthorized - localStorage.setItem("userStatus", "unauthorized"); - localStorage.removeItem("userID"); + + const errors = error.response.data.errors; + + if (errors && errors[0].details.includes("Login required")) { + localStorage.setItem("userStatus", "unauthorized"); + localStorage.removeItem("userID"); + } + + if (errors && errors[0].details.includes("Enrollment required")) { + localStorage.setItem("userStatus", "inactive"); + } + // if cookie expired, reload; // otherwise the user is not logged in and no need to reload. if (isCookieExpired) { @@ -22,16 +32,16 @@ axios.interceptors.response.use(null, (error) => { // reload the page. window.location.reload(); } - + // do not toast error message. return Promise.reject(error); } - if (error.response && error.response.status === 403) { - // the user has logged in but hasn't completed self-signup yet - // do not toast error message. - return Promise.reject(error); - } + // if (error.response && error.response.status === 403) { + // // the user has logged in but hasn't completed self-signup yet + // // do not toast error message. + // return Promise.reject(error); + // } // Timeout error. if(error.code === 'ECONNABORTED') { diff --git a/src/services/portalData.json b/src/services/portalData.json index b7d31260..2cd2a03d 100644 --- a/src/services/portalData.json +++ b/src/services/portalData.json @@ -3,6 +3,9 @@ "facilityOptions": ["FABRIC"], "sliverKeyLimit": 10, "bastionKeyLimit": 10, + "autoRefreshTokenInterval": 3300000, + "5minBeforeCookieExpires": 14100000, + "1minBeforeCookieExpires": 14340000, "selfEnrollRequest": { "id": "self-enroll-modal", "title": "FABRIC Self Signup", diff --git a/src/styles/FABRIC_CILogon.css b/src/styles/FABRIC_CILogon.css new file mode 100644 index 00000000..18ec653e --- /dev/null +++ b/src/styles/FABRIC_CILogon.css @@ -0,0 +1,73 @@ +body { + font-family: "PorximaNova-Light",sans-serif; + color: rgb(77,77,77); +} +a { +color: #5798bc; +text-decoration: none; +outline: none; +} +a:visited { +color: #5798bc; +} +a:hover, a:active { +color: #5798bc; +text-decoration: none; +} +.btn.btn-primary { +color: #5798bc; +background-color: #fff; +border: 1px solid #5798bc; +transition: 0.3s; +-moz-border-radius: 5px; +-webkit-border-radius: 5px; +} +.card-header { + background-color: rgb(229,246,255); +} +.btn.btn-primary:hover, +.btn.btn-primary.active, +.btn.btn-primary.active.focus, +.btn.btn-primary.active:focus, +.btn.btn-primary.active:hover, +.btn.btn-primary:active, +.btn.btn-primary:active.focus, +.btn.btn-primary:active:focus, +.btn.btn-primary:active:hover { +transition: 0.3s; +background-color: #08a7fd; +border: 1px solid #08a7fd; +color: white; +} +div.logoheader { +background: none; +height: 70px; +width: 100%; +min-width: 600px; +} +div.logoheader h1 { +background: transparent url("https://user-images.githubusercontent.com/37635744/212163129-b745ef15-cece-486d-82c7-755acd67a2a3.png") no-repeat top left; +background-size: 68%; +margin-top: 13px; +margin-left: 10%; +width: 489px; +height: 70px; +float: left; +} +div.skincilogonlogo { +display: inline; +margin-top: 12px; +margin-right: 8px; +} +.footer { +border: 1px solid #c0c2c5; +background: #fff; +} +.footer a:link, .footer a:visited { +color: #0288d0; +text-decoration: underline; +} +.footer a:hover, .footer a:active { +text-decoration: none; +color: #08a7fd; +} \ No newline at end of file diff --git a/src/utils/clearLocalStorage.js b/src/utils/clearLocalStorage.js new file mode 100644 index 00000000..0ab0df95 --- /dev/null +++ b/src/utils/clearLocalStorage.js @@ -0,0 +1,15 @@ +export default function clearLocalStorage() { + // clear Local Storage when user logs out. + // remove old user status stored in browser. + localStorage.removeItem("idToken"); + localStorage.removeItem("refreshToken"); + localStorage.removeItem("userID"); + localStorage.removeItem("bastionLogin"); + localStorage.removeItem("userStatus"); + localStorage.removeItem("sshKeyType"); + localStorage.removeItem("sliceDraft"); + localStorage.removeItem("countdownTimerIntervalId"); + localStorage.removeItem("sessionTimeoutIntervalId1"); + localStorage.removeItem("sessionTimeoutIntervalId2"); + localStorage.removeItem("refreshTokenIntervalId"); +} diff --git a/src/utils/manageTokens.js b/src/utils/manageTokens.js index e5afc6e1..e1fc10d5 100644 --- a/src/utils/manageTokens.js +++ b/src/utils/manageTokens.js @@ -1,4 +1,5 @@ import { createIdToken, refreshToken, revokeToken } from "../services/credentialManagerService.js"; +import { default as portalData } from "../services/portalData.json"; import { toast } from "react-toastify"; const autoRevokeTokens = async () => { @@ -6,19 +7,34 @@ const autoRevokeTokens = async () => { await revokeToken(localStorage.getItem("refreshToken")); } catch (err) { console.log("Failed to revoke token."); - // TO DO: what if revoke token fails? } } export const autoCreateTokens = async (projectId) => { + // clear previous autoRefreshToken interval if there is any + if (localStorage.getItem("refreshTokenIntervalId")) { + clearInterval(localStorage.getItem("refreshTokenIntervalId")); + } + try { // call credential manager to generate tokens. // parameters: project and scope, "all" for both by default. const { data: res } = await createIdToken(projectId, "all"); localStorage.setItem("idToken", res["data"][0].id_token); localStorage.setItem("refreshToken", res["data"][0].refresh_token); + + // Auto refresh token every 55min + const refreshTokenIntervalId = setInterval(() => { + if(localStorage.getItem("refreshTokenIntervalId")) { + clearInterval(localStorage.getItem("refreshTokenIntervalId")); + } + autoRefreshTokens(projectId); + } + , portalData["autoRefreshTokenInterval"]); + localStorage.setItem("refreshTokenIntervalId", refreshTokenIntervalId); return res["data"][0]; - } catch (err) { + } + catch (err) { toast.error("Unable to obtain authentication token, the likely reason is you are not a member of any projects."); } } diff --git a/src/utils/sliceValidator.js b/src/utils/sliceValidator.js index 20496ea9..6010329b 100644 --- a/src/utils/sliceValidator.js +++ b/src/utils/sliceValidator.js @@ -44,7 +44,7 @@ const validateSlice = (sliceName, sshKey, projectIdToGenerateToken, sliceNodes) return validationResult; } -const validateNodeComponents = (selectedSite, nodeName, nodes, core, ram, disk, nodeComponents) => { +const validateNodeComponents = (selectedSite, nodeName, nodes, core, ram, disk, nodeComponents, BootScript) => { const validationResult = { isValid: false, message: "", @@ -58,9 +58,9 @@ const validateNodeComponents = (selectedSite, nodeName, nodes, core, ram, disk, } // Node name must be unique in the graph. - if (nodeName === "") { + if (nodeName.length < 2) { validationResult.isValid = false; - validationResult.message = "Please enter a node name."; + validationResult.message = "Please enter a node name at least 2 characters."; return validationResult; } else { // check id node name is unique. @@ -84,6 +84,13 @@ const validateNodeComponents = (selectedSite, nodeName, nodes, core, ram, disk, return validationResult; } + // Boot script should be no more than 1024 characters. + if (BootScript.length > 1024) { + validationResult.isValid = false; + validationResult.message = "The max length supported for Boot Script is 1024 characters."; + return validationResult; + } + // all validation above are passed. validationResult.isValid = true; validationResult.message = ""; @@ -112,6 +119,13 @@ const validateSingleComponent = (type, name, model, addedComponents) => { return validationResult; } + if (name.length < 2) { + validationResult.isValid = false; + validationResult.message = "The component name should be at least 2 characters."; + validationResult.message = ""; + return validationResult; + } + if (model === "") { validationResult.isValid = false; // validationResult.message = "Please select a component model."; @@ -127,7 +141,7 @@ const validateSingleComponent = (type, name, model, addedComponents) => { return validationResult; } } - } + } if (type ==="" || name === "" || model === "") { validationResult.isValid = false; @@ -159,6 +173,13 @@ const validateDetailForm = (type, value, vm_id, nodes) => { validationResult.message = "Node name should not be empty."; return validationResult; } + + if (value.length < 2) { + validationResult.isValid = false; + validationResult.message = "Node name should be at least 2 characters."; + return validationResult; + } + // check the VM name is unique in the whole slice graph. // check id node name is unique. const vm_nodes = nodes.filter(node => node.Type === "VM" && node.id !== parseInt(vm_id)); @@ -196,9 +217,9 @@ const validateNetworkService = (serviceType, selectedCPs, serviceName, nodes) => message: "Please choose a service type.", }; - if (serviceName === "") { + if (serviceName.length < 2) { validationResult.isValid = false; - validationResult.message = "Please enter a service name."; + validationResult.message = "Please enter a service name at least 2 characters."; return validationResult; } else { // check if service name is unique