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