diff --git a/docs/portal_config.md b/docs/portal_config.md index e3bcabb1c9..d4087f38c7 100644 --- a/docs/portal_config.md +++ b/docs/portal_config.md @@ -182,6 +182,7 @@ Below is an example, with inline comments describing what each JSON block config "explorerFilterValuesToHide": ["array of strings"], // optional, Values set in array will be hidden in guppy filters. Intended use is to hide missing data category from filters, for this it should be set to the same as `missing_data_alias` in Guppy server config "studyRegistration": true, // optional, whether to enable the study registration feature "workspaceRegistration": true, // optional, whether to enable the workspace registration feature + "workspaceTokenServiceRefreshTokenAtLogin": true, // optional, whether to refresh the WTS token directly at portal login (recommended mode). If not set, this refresh happens only when the user enters the workspace section of the portal (default/old/previous mode). }, "dataExplorerConfig": { // required only if featureFlags.explorer is true; configuration for the Data Explorer (/explorer); can be replaced by explorerConfig, see Multi Tab Explorer doc "charts": { // optional; indicates which charts to display in the Data Explorer diff --git a/src/Analysis/GWASWizard/wizardEndpoints/cohortMiddlewareApi.js b/src/Analysis/GWASWizard/wizardEndpoints/cohortMiddlewareApi.js index 6f563a32db..87dc766d28 100644 --- a/src/Analysis/GWASWizard/wizardEndpoints/cohortMiddlewareApi.js +++ b/src/Analysis/GWASWizard/wizardEndpoints/cohortMiddlewareApi.js @@ -1,7 +1,6 @@ /* eslint-disable camelcase */ import { useState, useEffect } from 'react'; -import { cohortMiddlewarePath, wtsPath } from '../../../localconf'; -import { fetchWithCreds } from '../../../actions'; +import { cohortMiddlewarePath } from '../../../localconf'; import { headers } from '../../../configs'; import { hareConceptId } from '../shared/constants'; @@ -188,23 +187,11 @@ export const fetchSources = async () => { export const useSourceFetch = () => { const [loading, setLoading] = useState(true); const [sourceId, setSourceId] = useState(undefined); - const getSources = () => { // do wts login and fetch sources on initialization - fetchWithCreds({ - path: `${wtsPath}connected`, - method: 'GET', - }) - .then( - (res) => { - if (res.status !== 200) { - window.location.href = `${wtsPath}authorization_url?redirect=${window.location.pathname}`; - } else { - fetchSources().then((data) => { - setSourceId(data.sources[0].source_id); - setLoading(false); - }); - } - }, - ); + const getSources = () => { // fetch sources on initialization + fetchSources().then((data) => { + setSourceId(data.sources[0].source_id); + setLoading(false); + }); }; useEffect(() => { getSources(); diff --git a/src/Login/Login.jsx b/src/Login/Login.jsx index 03725d2b31..c1908bc8d5 100644 --- a/src/Login/Login.jsx +++ b/src/Login/Login.jsx @@ -10,6 +10,29 @@ import './Login.less'; const getInitialState = (height) => ({ height }); +// Get a url for a given "location" (location object should have at least the .from attribute) +export const getUrlForRedirectLocation = (location) => { + // compose next according to location.from + let next = (location.from) ? `${basename}${location.from}` : basename; + if (location.state && location.state.from) { + next = `${basename}${location.state.from}`; + } + // clean up url: no double slashes + next = next.replace(/\/+/g, '/'); + const queryParams = querystring.parse(location.search ? location.search.replace(/^\?+/, '') : ''); + if (queryParams.next) { + next = basename === '/' ? queryParams.next : basename + queryParams.next; + } + const regexp = /^\/.*/gi; + const isValidRedirect = new RegExp(regexp).test(next); + if (!isValidRedirect) { + console.log(`Found illegal "next" parameter value ${next}`); + return basename; + } + next = next.replace('?request_access', '?request_access_logged_in'); + return `${next}`; +}; + const getLoginUrl = (providerLoginUrl, next) => { const queryChar = providerLoginUrl.includes('?') ? '&' : '?'; return `${providerLoginUrl}${queryChar}redirect=${window.location.origin}${next}`; @@ -50,18 +73,7 @@ class Login extends React.Component { render() { const { location } = this.props; // this is the react-router "location" - // compose next according to location.from - let next = (location.from) ? `${basename}${location.from}` : basename; - if (location.state && location.state.from) { - next = `${basename}${location.state.from}`; - } - // clean up url: no double slashes - next = next.replace(/\/+/g, '/'); - const queryParams = querystring.parse(location.search ? location.search.replace(/^\?+/, '') : ''); - if (queryParams.next) { - next = basename === '/' ? queryParams.next : basename + queryParams.next; - } - + const next = getUrlForRedirectLocation(location); let customImage = 'gene'; let displaySideBoxImages = true; if (components.login && components.login.image !== undefined) { @@ -72,7 +84,6 @@ class Login extends React.Component { } } const customImageStyle = { backgroundImage: `url(/src/img/icons/${customImage}.svg)` }; - next = next.replace('?request_access', '?request_access_logged_in'); let loginComponent = ( diff --git a/src/Login/ProtectedContent.jsx b/src/Login/ProtectedContent.jsx index 0525523ee0..cc6c87c986 100644 --- a/src/Login/ProtectedContent.jsx +++ b/src/Login/ProtectedContent.jsx @@ -13,6 +13,8 @@ import ReduxAuthTimeoutPopup from '../Popup/ReduxAuthTimeoutPopup'; import ReduxSystemUseWarningPopup from '../Popup/SystemUseWarningPopup'; import { intersection, isPageFullScreen } from '../utils'; import './ProtectedContent.css'; +import isEnabled from '../helpers/featureFlags'; +import { initWorkspaceRefreshToken } from '../Workspace/WorkspaceRefreshToken'; let lastAuthMs = 0; @@ -27,8 +29,8 @@ let lastAuthMs = 0; * @param filter {() => Promise} optional filter to apply before rendering the child component */ class ProtectedContent extends React.Component { - constructor(props, context) { - super(props, context); + constructor(props) { + super(props); this.state = { authenticated: false, dataLoaded: false, @@ -56,8 +58,8 @@ class ProtectedContent extends React.Component { ) .then( () => this.checkLoginStatus(store, this.state) - .then((newState) => this.props.public || this.checkQuizStatus(newState)) - .then((newState) => this.props.public || this.checkApiToken(store, newState)), + .then((newState) => ((this.props.public) ? { ...newState, redirectTo: null } : this.checkQuizStatus(newState))) // don't redirect for public pages + .then((newState) => ((this.props.public) ? { ...newState, redirectTo: null } : this.checkApiToken(store, newState))), ) .then( (newState) => { @@ -70,6 +72,11 @@ class ProtectedContent extends React.Component { const latestState = { ...newState }; latestState.dataLoaded = true; this.setState(latestState); + if (newState.authenticated && isEnabled('workspaceTokenServiceRefreshTokenAtLogin')) { + // initialize WTS: + const { location } = this.props; // this is the react-router "location" + initWorkspaceRefreshToken(location); + } }; return filterPromise.then( finish, finish, diff --git a/src/Workspace/WorkspaceRefreshToken.js b/src/Workspace/WorkspaceRefreshToken.js new file mode 100644 index 0000000000..cce885a0e6 --- /dev/null +++ b/src/Workspace/WorkspaceRefreshToken.js @@ -0,0 +1,30 @@ +import { fetchWithCreds } from '../actions'; +import { wtsPath } from '../localconf'; +import { getUrlForRedirectLocation } from '../Login/Login'; + +let lastRefreshMs = 0; +const debounceMs = 60000; + +// start workspace session for WTS, call optional connectedCallBack if initialized/connected +/* eslint-disable import/prefer-default-export */ +export const initWorkspaceRefreshToken = (redirectLocation, connectedCallBack) => { + const redirectUrl = getUrlForRedirectLocation(redirectLocation); + const nowMs = Date.now(); + if (nowMs - lastRefreshMs > debounceMs) { + console.log('init/renew WTS refresh token...'); + fetchWithCreds({ + path: `${wtsPath}connected`, + method: 'GET', + }) + .then( + ({ status }) => { + if (status !== 200) { + window.location.href = `${wtsPath}/authorization_url?redirect=${redirectUrl}`; + } else if (connectedCallBack) { + connectedCallBack(); + } + }, + ); + lastRefreshMs = Date.now(); + } +}; diff --git a/src/Workspace/index.jsx b/src/Workspace/index.jsx index fb5941ceef..c279c9e06e 100644 --- a/src/Workspace/index.jsx +++ b/src/Workspace/index.jsx @@ -15,7 +15,6 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import isEnabled from '../helpers/featureFlags'; import { workspaceUrl, - wtsPath, externalLoginOptionsUrl, workspaceOptionsUrl, workspaceLaunchUrl, @@ -42,6 +41,7 @@ import WorkspaceOption from './WorkspaceOption'; import WorkspaceLogin from './WorkspaceLogin'; import sessionMonitor from '../SessionMonitor'; import workspaceSessionMonitor from './WorkspaceSessionMonitor'; +import { initWorkspaceRefreshToken } from './WorkspaceRefreshToken'; const { Step } = Steps; const { Panel } = Collapse; @@ -73,19 +73,15 @@ class Workspace extends React.Component { } componentDidMount() { - fetchWithCreds({ - path: `${wtsPath}connected`, - method: 'GET', - }) - .then( - ({ status }) => { - if (status !== 200) { - window.location.href = `${wtsPath}/authorization_url?redirect=${window.location.pathname}`; - } else { - this.connected(); - } - }, - ); + // Check if workspaceTokenServiceRefreshTokenAtLogin is NOT set. + // Because if is already enabled, then an extra refresh is not + // really needed, since it has already happened at login, so just call the callback: + if (!isEnabled('workspaceTokenServiceRefreshTokenAtLogin')) { + const redirectLocation = { from: `${window.location.pathname}` }; + initWorkspaceRefreshToken(redirectLocation, this.connected); + } else { + this.connected(); + } } componentWillUnmount() {