Skip to content

Commit

Permalink
Feature/session timeout modal (#240)
Browse files Browse the repository at this point in the history
* #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
  • Loading branch information
yaxue1123 authored Jan 13, 2023
1 parent dbeaefb commit 62bd416
Show file tree
Hide file tree
Showing 14 changed files with 351 additions and 108 deletions.
72 changes: 58 additions & 14 deletions src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand All @@ -29,6 +31,8 @@ class App extends React.Component {
state = {
userStatus: "",
activeNotices: [],
showSessionTimeoutModal1: false,
showSessionTimeoutModal2: false,
};

async componentDidMount() {
Expand All @@ -42,27 +46,53 @@ 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");
}
}
}

this.setState({ userStatus: localStorage.getItem("userStatus") });
}

render() {
const { showSessionTimeoutModal1, showSessionTimeoutModal2 } = this.state;
return (
<div className="App">
<Router>
Expand All @@ -75,6 +105,20 @@ class App extends React.Component {
/>
)
}
{
showSessionTimeoutModal1 &&
<SessionTimeoutModal
modalId={1}
timeLeft={300000}
/>
}
{
showSessionTimeoutModal2 &&
<SessionTimeoutModal
modalId={2}
timeLeft={60000}
/>
}
<Switch>
<Route path="/" component={Home} exact />
<Route path="/login" component={Home} />
Expand All @@ -85,7 +129,7 @@ class App extends React.Component {
<Route path="/signup/:id" component={Signup} />
<Route path="/resources" component={Resources} />
<Route path="/help" component={Help} />
<ProtectedRoute path="/slices/:id" component={SliceViewer} />
<ProtectedRoute path="/slices/:slice_id,:project_id" component={SliceViewer} />
<ProtectedRoute path="/new-slice" component={NewSliceForm} />
<ProtectedRoute path="/projects/:id" component={ProjectForm} />
<ProtectedRoute path="/projects" component={Projects} />
Expand Down
23 changes: 3 additions & 20 deletions src/components/Experiment/Slices.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.");
}
}

Expand Down
33 changes: 19 additions & 14 deletions src/components/Header.jsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -61,30 +60,36 @@ 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;
return (
<nav className="navbar navbar-expand-lg navbar-light bg-light">
<NavLink className="navbar-brand" to="/">
<img
src={logo}
src={this.getLogoSrc()}
width="70"
height="30"
className="d-inline-block align-top"
Expand Down
100 changes: 100 additions & 0 deletions src/components/Modals/SessionTimeoutModal.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import React, { Component } from "react";
import Modal from 'react-bootstrap/Modal'
import Button from 'react-bootstrap/Button'
import clearLocalStorage from "../../utils/clearLocalStorage";

class SessionTimeoutModal extends Component {
state = {
show: true,
minutes: 0,
seconds: 0,
}

handleLogout = () => {
this.setState({ show: false });
clearLocalStorage();
window.location.href = "/logout";
}

handleClose = () => {
this.setState({ show: false });
}

componentDidMount() {
let minutes = Math.floor(this.props.timeLeft / 60000);
let seconds = ((this.props.timeLeft % 60000) / 1000).toFixed(0);
this.setState({ minutes, seconds })

let countdownTimer = setInterval(() => {
if(seconds > 0){
seconds--;
} else if (minutes > 0){
minutes--;
seconds = 59;
} else {
minutes = 0;
seconds = 0;
}
this.setState({ minutes, seconds })
}, 1000);

localStorage.setItem("countdownTimerIntervalId", countdownTimer);
}

parseTimeStr = (minutes, seconds) => {
if (minutes > 0 && seconds > 0) {
return `${minutes} minute${minutes > 1 ? "s" : ""} ${seconds} second${seconds > 1 ? "s" : ""}`;
}

if (minutes > 0 && seconds === 0) {
return `${minutes} minute${minutes > 1 ? "s" : ""}`;
}

if (minutes === 0 && seconds > 1) {
return `${seconds} second${seconds > 1 ? "s" : ""}`;
}

if (minutes === 0 && seconds === 1) {
clearInterval(localStorage.getItem("countdownTimerIntervalId"));
clearInterval(localStorage.getItem(`sessionTimeoutIntervalId${this.props.modalId}`));
this.handleLogout();
}
}

render() {
let { minutes, seconds, show } = this.state;
return (
<div>
{
this.props.timeLeft > 0 && <Modal
size="lg"
show={show}
onHide={this.handleClose}
backdrop="static"
keyboard={false}
>
<Modal.Header closeButton>
<Modal.Title>Session Timeout</Modal.Title>
</Modal.Header>
<Modal.Body>
<p id="countdownTimerModal">
The current session is about to expire in <span className="text-danger font-weight-bold">
{this.parseTimeStr(minutes, seconds)}</span>.
Please save your work to prevent loss of data.
</p>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={this.handleClose}>Close</Button>
<Button variant="primary" onClick={this.handleLogout}>
Logout
</Button>
</Modal.Footer>
</Modal>

}
</div>
);
}
}

export default SessionTimeoutModal;
2 changes: 1 addition & 1 deletion src/components/Slice/SlicesTable.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ class SlicesTable extends Component {
path: "name",
label: "Slice Name",
content: (slice) => (
<Link to={`/slices/${slice.slice_id}`}>{slice.name}</Link>
<Link to={`/slices/${slice.slice_id},${slice.project_id}`}>{slice.name}</Link>
),
},
{ path: "state", label: "Slice State" },
Expand Down
1 change: 1 addition & 0 deletions src/components/SliceViewer/SideLinks.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export default class SideLinks extends Component {
id="inputServiceName"
value={linkName}
onChange={this.handleLinkNameChange}
placeholder={"at least 2 characters..."}
/>
</div>
</div>
Expand Down
3 changes: 2 additions & 1 deletion src/components/SliceViewer/SideNodes.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => (
<Tooltip id={id}>
{content}
Expand Down Expand Up @@ -237,6 +237,7 @@ class SideNodes extends React.Component {
id="inputNodeName"
value={nodeName}
onChange={this.handleNameChange}
placeholder={"at least 2 characters..."}
/>
</div>
</div>
Expand Down
Loading

0 comments on commit 62bd416

Please sign in to comment.