From 74f85e2f47725996f25ccc64b0cbe3fa124783b4 Mon Sep 17 00:00:00 2001
From: Sunny Aggarwal
Date: Mon, 5 Oct 2026 20:36:11 +0530
Subject: [PATCH 1/2] OpenConceptLab/ocl_online#374 | using path routing in
place of hash routing
---
public/index.html | 39 ++++----
src/common/__tests__/history.test.js | 101 ++++++++++++++++++++
src/common/history.js | 81 ++++++++++++++++
src/common/utils.js | 36 ++++---
src/components/app/App.jsx | 7 +-
src/components/app/LeftMenu.jsx | 2 +-
src/components/common/BaseEntityChip.jsx | 2 +-
src/components/common/OCLLogo.jsx | 2 +-
src/components/concepts/ConceptDetails.jsx | 2 +-
src/components/errors/Error403.jsx | 2 +-
src/components/errors/ErrorBoundary.jsx | 2 +-
src/components/errors/WaitListing.jsx | 4 +-
src/components/map-projects/MapProject.jsx | 2 +-
src/components/map-projects/MapProjects.jsx | 2 +-
src/components/users/OIDLoginCallback.jsx | 9 +-
src/index.jsx | 21 +++-
src/services/GAService.js | 2 +-
webpack.config.js | 4 +-
18 files changed, 263 insertions(+), 57 deletions(-)
create mode 100644 src/common/__tests__/history.test.js
create mode 100644 src/common/history.js
diff --git a/public/index.html b/public/index.html
index 21310d56..ae00cfa0 100644
--- a/public/index.html
+++ b/public/index.html
@@ -8,7 +8,7 @@
manifest.json provides metadata used when your web app is added to the
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
-->
-
+
-
+
-
+
-
+
diff --git a/src/common/__tests__/history.test.js b/src/common/__tests__/history.test.js
new file mode 100644
index 00000000..9056c070
--- /dev/null
+++ b/src/common/__tests__/history.test.js
@@ -0,0 +1,101 @@
+/* global globalThis */
+import test from 'node:test'
+import assert from 'node:assert/strict'
+
+const location = { origin: 'http://localhost:4004', pathname: '/map-projects/', search: '', hash: '', href: 'http://localhost:4004/map-projects/' }
+globalThis.window = { location }
+
+const { legacyHashRoute, toRoutePath, navigate, setAppHistory, handleLinkClick, keepLinkClickBubbling } = await import('../history.js')
+
+const loc = (hash, search='') => ({ hash, search })
+
+test('legacyHashRoute turns a hash route into a path', () => {
+ assert.equal(legacyHashRoute(loc('#/map-projects/')), '/map-projects/')
+ assert.equal(legacyHashRoute(loc('#/orgs/CIEL/sources/CIEL/?q=malaria')), '/orgs/CIEL/sources/CIEL/?q=malaria')
+})
+
+test('legacyHashRoute keeps a nested referrer hash intact', () => {
+ assert.equal(
+ legacyHashRoute(loc('#/map-projects/1/?referrer=https://app.v3/#/orgs/X/?auth=true')),
+ '/map-projects/1/?referrer=https://app.v3/#/orgs/X/?auth=true'
+ )
+})
+
+test('legacyHashRoute merges the outer query into the route', () => {
+ assert.equal(legacyHashRoute(loc('#/search/', '?foo=1')), '/search/?foo=1')
+ assert.equal(legacyHashRoute(loc('#/search/?q=1', '?foo=1')), '/search/?q=1&foo=1')
+})
+
+test('legacyHashRoute ignores in-page anchors, empty hashes and referrer hashes', () => {
+ assert.equal(legacyHashRoute(loc('#heading')), null)
+ assert.equal(legacyHashRoute(loc('')), null)
+ assert.equal(legacyHashRoute(loc('#/orgs/X/?auth=true', '?referrer=https://app.v3/')), null)
+})
+
+test('toRoutePath strips a legacy hash prefix only', () => {
+ assert.equal(toRoutePath('/#/map-projects/new'), '/map-projects/new')
+ assert.equal(toRoutePath('#/map-projects/new'), '/map-projects/new')
+ assert.equal(toRoutePath('/map-projects/new'), '/map-projects/new')
+})
+
+const pushed = []
+setAppHistory({ push: path => pushed.push(['push', path]), replace: path => pushed.push(['replace', path]) })
+
+test('navigate pushes or replaces through the router history', () => {
+ pushed.length = 0
+ navigate('/#/map-projects/new')
+ navigate('/search/', true)
+ assert.deepEqual(pushed, [['push', '/map-projects/new'], ['replace', '/search/']])
+})
+
+const anchor = (href, attrs={}) => ({
+ href: new URL(href, location.href).href,
+ target: attrs.target || '',
+ getAttribute: name => name === 'href' ? href : null,
+ hasAttribute: name => Boolean(attrs[name]),
+})
+
+const click = (a, extra={}) => {
+ const event = {
+ button: 0, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, defaultPrevented: false,
+ target: { closest: () => a },
+ preventDefault() { this.defaultPrevented = true },
+ stopPropagation() { this.stopped = true },
+ ...extra,
+ }
+ return event
+}
+
+test('handleLinkClick routes same-origin links in the app', () => {
+ pushed.length = 0
+ const event = click(anchor('/map-projects/new'))
+ handleLinkClick(event)
+ assert.equal(event.defaultPrevented, true)
+ assert.deepEqual(pushed, [['push', '/map-projects/new']])
+})
+
+test('handleLinkClick leaves new-tab, modified, external and prevented clicks alone', () => {
+ pushed.length = 0
+ const cases = [
+ click(anchor('/map-projects/new', { target: '_blank' })),
+ click(anchor('/map-projects/new'), { metaKey: true }),
+ click(anchor('/map-projects/new'), { button: 1 }),
+ click(anchor('https://app.v3.openconceptlab.org/#/orgs/CIEL/')),
+ click(anchor('#section')),
+ click(anchor('/map-projects/new'), { defaultPrevented: true }),
+ ]
+ cases.forEach(event => handleLinkClick(event))
+ assert.deepEqual(pushed, [])
+})
+
+test('keepLinkClickBubbling neutralises stopPropagation only for app links', () => {
+ const appLink = click(anchor('/map-projects/new'))
+ keepLinkClickBubbling(appLink)
+ appLink.stopPropagation()
+ assert.equal(appLink.stopped, undefined)
+
+ const external = click(anchor('https://openconceptlab.org/'))
+ keepLinkClickBubbling(external)
+ external.stopPropagation()
+ assert.equal(external.stopped, true)
+})
diff --git a/src/common/history.js b/src/common/history.js
new file mode 100644
index 00000000..9f8dc50b
--- /dev/null
+++ b/src/common/history.js
@@ -0,0 +1,81 @@
+let appHistory = null
+
+export const setAppHistory = history => {
+ appHistory = history
+}
+
+export const toRoutePath = path => {
+ const legacy = path?.match(/^\/?#(\/.*)$/)
+ return legacy ? legacy[1] : path
+}
+
+export const navigate = (path, replace=false) => {
+ if(!path)
+ return
+ const routePath = toRoutePath(path)
+ if(!appHistory) {
+ if(replace)
+ window.location.replace(routePath)
+ else
+ window.location.assign(routePath)
+ return
+ }
+ if(replace)
+ appHistory.replace(routePath)
+ else
+ appHistory.push(routePath)
+}
+
+export const legacyHashRoute = (location=window.location) => {
+ const { hash, search } = location
+ if(!hash.startsWith('#/') || /[?&]referrer=/.test(search))
+ return null
+ let route = hash.slice(1)
+ const outer = search.replace(/^\?/, '')
+ if(outer) {
+ const hashAt = route.indexOf('#')
+ const tail = hashAt > -1 ? route.slice(hashAt) : ''
+ const head = hashAt > -1 ? route.slice(0, hashAt) : route
+ route = head + (head.includes('?') ? '&' : '?') + outer + tail
+ }
+ return route
+}
+
+export const redirectLegacyHashRoute = () => {
+ const route = legacyHashRoute()
+ if(route)
+ navigate(route, true)
+}
+
+const currentRoute = () => window.location.pathname + window.location.search + window.location.hash
+
+const isPlainLeftClick = event => event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey
+
+const appLinkRoute = event => {
+ const anchor = event.target?.closest?.('a[href]')
+ if(!anchor || (anchor.target && anchor.target !== '_self') || anchor.hasAttribute('download'))
+ return null
+ const href = anchor.getAttribute('href')
+ if(!href || (href.startsWith('#') && !href.startsWith('#/')) || /^(mailto|tel|javascript):/i.test(href))
+ return null
+ const url = new URL(anchor.href, window.location.href)
+ if(url.origin !== window.location.origin)
+ return null
+ return legacyHashRoute(url) || url.pathname + url.search + url.hash
+}
+
+export const keepLinkClickBubbling = event => {
+ if(isPlainLeftClick(event) && appLinkRoute(event))
+ event.stopPropagation = () => {}
+}
+
+export const handleLinkClick = event => {
+ if(event.defaultPrevented || !isPlainLeftClick(event))
+ return
+ const route = appLinkRoute(event)
+ if(!route)
+ return
+ event.preventDefault()
+ if(route !== currentRoute())
+ navigate(route)
+}
diff --git a/src/common/utils.js b/src/common/utils.js
index 9797bd73..cc36e2b5 100644
--- a/src/common/utils.js
+++ b/src/common/utils.js
@@ -15,12 +15,13 @@ import {
import APIService from '../services/APIService';
import GAService from '../services/GAService';
import { SERVER_CONFIGS } from './serverConfigs';
+import { legacyHashRoute } from './history';
-export const currentPath = () => window.location.hash.split('?')[0];
+export const currentPath = () => window.location.pathname;
-export const isAtGlobalSearch = () => window.location.hash.includes('#/search') || isAtRoot();
+export const isAtGlobalSearch = () => currentPath().startsWith('/search') || isAtRoot();
-export const isAtRoot = () => currentPath() === '#/';
+export const isAtRoot = () => currentPath() === '/';
export const formatDate = date => moment(date).format(DATE_FORMAT);
export const formatTime = date => moment(date).format(TIME_FORMAT);
@@ -210,7 +211,7 @@ export const arrayToObject = arr => {
}, {});
}
-export const currentUserHasAccess = () => hasAccessToURL(window.location.hash.replace('#/', ''))
+export const currentUserHasAccess = () => hasAccessToURL(window.location.pathname)
export const hasAccessToURL = url => {
if(!isLoggedIn())
@@ -657,12 +658,12 @@ export const logoutUser = (redirectToLogin, forced) => {
localStorage.removeItem('visits');
}
- const returnTo = window.location.origin + '/' + window.location.hash
+ const returnTo = window.location.origin + window.location.pathname + window.location.search
if(forced)
sessionStorage.setItem('session_expired', 'true')
const redirectURL = forced ?
- window.location.origin + '/#/signin?returnTo=' + encodeURIComponent(returnTo) :
+ window.location.origin + '/signin?returnTo=' + encodeURIComponent(returnTo) :
undefined
const logoutURL = getSSOLogoutURL(redirectURL)
@@ -671,10 +672,8 @@ export const logoutUser = (redirectToLogin, forced) => {
window.location = logoutURL
else if(redirectToLogin)
getLoginURL(forced ? returnTo : undefined).then(url => { window.location.href = url })
- else {
- window.location.hash = '#/';
- window.location.reload();
- }
+ else
+ window.location.assign('/');
}
@@ -877,9 +876,22 @@ const routePath = route => {
// Keycloak only redeems a code when the token request repeats the sign-in's redirect_uri exactly, and the
// callback can't rebuild a page's query string (e.g. ?referrer= on links from openconceptlab.org). So sign-in
-// always goes through LOGIN_REDIRECT_URL, and the page to come back to (its hash route) waits here, in this tab.
+// always goes through LOGIN_REDIRECT_URL, and the page to come back to (its route) waits here, in this tab.
+const returnToRoute = returnTo => {
+ if(!returnTo)
+ return null
+ try {
+ const url = new URL(returnTo, window.location.origin)
+ if(url.origin !== window.location.origin)
+ return null
+ return legacyHashRoute(url) || url.pathname + url.search + url.hash
+ } catch {
+ return null
+ }
+}
+
const prepareOAuthReturnTo = returnTo => {
- const route = returnTo?.includes('#') ? returnTo.slice(returnTo.indexOf('#') + 1) : null
+ const route = returnToRoute(returnTo)
const path = route && routePath(route)
// The router matches paths case-insensitively, so /SIGNUP would start a sign-up too.
if(path?.startsWith('/') && !/^\/(oidc\/login|signin|signup)(\/|$)/i.test(path))
diff --git a/src/components/app/App.jsx b/src/components/app/App.jsx
index a7f24d0f..7ec05fe9 100644
--- a/src/components/app/App.jsx
+++ b/src/components/app/App.jsx
@@ -87,14 +87,13 @@ const App = props => {
const queryParams = new URLSearchParams(search)
const referrer = queryParams.get('referrer')
if(isLoggedIn()) {
- // OIDLoginCallback navigates away itself; resetting the hash here would undo that.
- if(!pathname.startsWith('/oidc/login'))
- window.location.hash = '#' + pathname
+ if(hash && !pathname.startsWith('/oidc/login'))
+ props.history.replace(pathname + search)
} else if(isOtherOCLClientURL(referrer) && !isLoggedIn()) {
const parts = hash ? hash.split('?') : referrer.split('?')
let params = new URLSearchParams(parts[1])
if(params.get('auth') === 'true') {
- getLoginURL(window.location.origin + '/#' + pathname).then(url => { window.location.href = url })
+ getLoginURL(window.location.origin + pathname).then(url => { window.location.href = url })
}
}
}
diff --git a/src/components/app/LeftMenu.jsx b/src/components/app/LeftMenu.jsx
index c4b32c4f..56d0120f 100644
--- a/src/components/app/LeftMenu.jsx
+++ b/src/components/app/LeftMenu.jsx
@@ -119,7 +119,7 @@ const LeftMenu = ({ isOpen, onClose }) => {
px: 2,
borderRadius: '100px'
}}
- href='/#/'
+ href='/'
className='no-anchor-styles'
selected={location.pathname === '/'}
>
diff --git a/src/components/common/BaseEntityChip.jsx b/src/components/common/BaseEntityChip.jsx
index 5fcd3250..83ce61b9 100644
--- a/src/components/common/BaseEntityChip.jsx
+++ b/src/components/common/BaseEntityChip.jsx
@@ -172,7 +172,7 @@ const BaseEntityChip = ({ entity, icon, hideType, primary, size, sx, noLink, isV
onClick={noLink ? undefined : event => {
event.stopPropagation()
}}
- href={noLink ? undefined : '#' + (toV3URL(entity?.version_url || entity?.url))}
+ href={noLink ? undefined : toV3URL(entity?.version_url || entity?.url)}
component='a'
target='_blank'
rel='noreferrer noopener'
diff --git a/src/components/common/OCLLogo.jsx b/src/components/common/OCLLogo.jsx
index 807956f9..37d4368c 100644
--- a/src/components/common/OCLLogo.jsx
+++ b/src/components/common/OCLLogo.jsx
@@ -4,7 +4,7 @@ import { PRIMARY_COLORS } from '../../common/colors'
const OCLLogo = ({ color, width, height, }) => {
return (
-
+
diff --git a/src/components/errors/ErrorBoundary.jsx b/src/components/errors/ErrorBoundary.jsx
index 44c74194..b22284a2 100644
--- a/src/components/errors/ErrorBoundary.jsx
+++ b/src/components/errors/ErrorBoundary.jsx
@@ -61,7 +61,7 @@ class ErrorBoundary extends React.Component {
getErrorUIProps() {
const props = {header: 'Error', message: isString(this.state.error) ? this.state.error : 'Something went wrong.'}
- if(window.location.hash.match(/debug=true/))
+ if(window.location.search.match(/debug=true/))
return {...props, error: this.state.error, errorInfo: this.state.errorInfo}
return props
diff --git a/src/components/errors/WaitListing.jsx b/src/components/errors/WaitListing.jsx
index 0dfd7dca..fcc9b4b7 100644
--- a/src/components/errors/WaitListing.jsx
+++ b/src/components/errors/WaitListing.jsx
@@ -65,7 +65,7 @@ const WaitListing = () => {
@@ -145,7 +145,7 @@ const WaitListing = () => {
{
return
}
if(project?.url) {
- window.open(`/#/map-projects/new?templateFrom=${encodeURIComponent(project.url)}`, '_blank', 'noopener,noreferrer')
+ window.open(`/map-projects/new?templateFrom=${encodeURIComponent(project.url)}`, '_blank', 'noopener,noreferrer')
}
}
diff --git a/src/components/map-projects/MapProjects.jsx b/src/components/map-projects/MapProjects.jsx
index 6c232922..85599a41 100644
--- a/src/components/map-projects/MapProjects.jsx
+++ b/src/components/map-projects/MapProjects.jsx
@@ -161,7 +161,7 @@ const MapProjects = () => {
variant='contained'
color='primary'
startIcon={}
- href='#/map-projects/new'
+ href='/map-projects/new'
onClick={onNewProjectClick}
sx={{textTransform: 'none'}}
>
diff --git a/src/components/users/OIDLoginCallback.jsx b/src/components/users/OIDLoginCallback.jsx
index 36b98cf5..980a4a26 100644
--- a/src/components/users/OIDLoginCallback.jsx
+++ b/src/components/users/OIDLoginCallback.jsx
@@ -6,6 +6,7 @@ import {
refreshCurrentUserCache, consumeStoredPKCECodeVerifier, consumeAndValidateOAuthState, consumeOAuthReturnTo,
isSignupOAuthState, isLoggedIn, getLoginURL
} from '../../common/utils';
+import { navigate } from '../../common/history';
import APIService from '../../services/APIService'
import GAService from '../../services/GAService'
import { OperationsContext } from '../app/LayoutContext';
@@ -84,15 +85,15 @@ class OIDLoginCallback extends React.Component {
)
})
- window.location.hash = '#' + (next || '/')
+ navigate(next || '/', true)
}
cacheUserData() {
refreshCurrentUserCache(() => {
if(this.state.returnTo)
- window.location.hash = '#' + this.state.returnTo
+ navigate(this.state.returnTo, true)
else if(this.state.next)
- window.location.hash = '#' + this.state.next
+ navigate(this.state.next, true)
else {
let returnToURL = '/'
if(this.props?.location?.search) {
@@ -100,7 +101,7 @@ class OIDLoginCallback extends React.Component {
if(queryParams && queryParams.get('returnTo'))
returnToURL = queryParams.get('returnTo')
}
- window.location.hash = '#' + returnToURL
+ navigate(returnToURL, true)
}
})
}
diff --git a/src/index.jsx b/src/index.jsx
index e3ffe633..c7b8cca6 100644
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -1,13 +1,14 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import Fade from '@mui/material/Fade';
-import { HashRouter } from 'react-router-dom';
+import { BrowserRouter, useHistory } from 'react-router-dom';
import { ThemeProvider, StyledEngineProvider, createTheme, alpha } from '@mui/material/styles';
import App from './components/app/App';
import LayoutContext from './components/app/LayoutContext';
import './index.scss';
import { COLORS } from './common/colors';
import './i18n/config';
+import { setAppHistory, handleLinkClick, keepLinkClickBubbling, legacyHashRoute, redirectLegacyHashRoute } from './common/history';
const theme = createTheme();
const v5Theme = createTheme(theme, {
@@ -121,14 +122,28 @@ const v5Theme = createTheme(theme, {
})
+const HistoryBridge = () => {
+ setAppHistory(useHistory())
+ return null
+}
+
+const legacyRoute = legacyHashRoute()
+if(legacyRoute)
+ window.history.replaceState(null, '', legacyRoute)
+
+window.addEventListener('click', keepLinkClickBubbling, true)
+document.addEventListener('click', handleLinkClick)
+window.addEventListener('hashchange', redirectLegacyHashRoute)
+
const container = document.getElementById('root');
const root = createRoot(container);
root.render(
-
+
+
)} />
-
+
);
diff --git a/src/services/GAService.js b/src/services/GAService.js
index 21525a1d..c0da9a69 100644
--- a/src/services/GAService.js
+++ b/src/services/GAService.js
@@ -25,7 +25,7 @@ const GAService = {
if(!enabled())
return;
- ReactGA.send({ hitType: 'pageview', page: window.location.pathname + window.location.hash.split('?')[0] });
+ ReactGA.send({ hitType: 'pageview', page: window.location.pathname });
},
recordUpsertEvent(category, edit, resource) {
diff --git a/webpack.config.js b/webpack.config.js
index 80bddab4..4bfbc617 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -9,6 +9,7 @@ module.exports = (env) => {
return {
mode: env.NODE_ENV,
output: {
+ publicPath: '/',
// Production names carry a content hash (OpenConceptLab/ocl_issues#2824),
// so a changed bundle gets a new URL. nginx caches these files for a year
// and revalidates index.html on every load, so a returning browser never
@@ -101,7 +102,8 @@ module.exports = (env) => {
directory: path.join(__dirname, 'public')
},
historyApiFallback: {
- index: 'index.html',
+ index: '/index.html',
+ disableDotRule: true,
}
},
devtool: env.NODE_ENV == 'production' ? "source-map" : undefined,
From 6ab7f9fc7aa99308213b7a083f5f23b64d049995 Mon Sep 17 00:00:00 2001
From: Sunny Aggarwal
Date: Wed, 7 Oct 2026 09:15:22 +0530
Subject: [PATCH 2/2] OpenConceptLab/ocl_online#374 | review feedbacks
---
public/index.html | 2 --
src/common/__tests__/history.test.js | 35 ++++++++++++++++++++++++++++
src/common/history.js | 14 +++++++----
src/common/utils.js | 8 +++----
src/components/app/App.jsx | 2 +-
5 files changed, 49 insertions(+), 12 deletions(-)
diff --git a/public/index.html b/public/index.html
index ae00cfa0..9523e18e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -44,8 +44,6 @@
go('/oidc/login/' + loc.search + '&next=' + loc.pathname);
} else if(loc.href.includes('/?session_state=')) {
go('/');
- } else if((loc.search || '').includes('origin=openmrs')) {
- go('/search/?origin=openmrs&type=organizations');
}
})();
diff --git a/src/common/__tests__/history.test.js b/src/common/__tests__/history.test.js
index 9056c070..64ca5b85 100644
--- a/src/common/__tests__/history.test.js
+++ b/src/common/__tests__/history.test.js
@@ -99,3 +99,38 @@ test('keepLinkClickBubbling neutralises stopPropagation only for app links', ()
external.stopPropagation()
assert.equal(external.stopped, true)
})
+
+test('legacyHashRoute rejects routes that would leave the site', () => {
+ assert.equal(legacyHashRoute(loc('#//example.com/')), null)
+ assert.equal(legacyHashRoute(loc('#/\\example.com/')), null)
+})
+
+test('navigate ignores anything but a single-slash path', () => {
+ pushed.length = 0
+ navigate('//example.com/')
+ navigate('/\\example.com/')
+ navigate('/#//example.com/')
+ navigate('https://example.com/')
+ navigate(undefined)
+ assert.deepEqual(pushed, [])
+})
+
+test('a relative #/ link goes to that route without the current query', () => {
+ pushed.length = 0
+ location.search = '?q=malaria&type=concepts'
+ const event = click(anchor('#/map-projects/new'))
+ handleLinkClick(event)
+ location.search = ''
+ assert.deepEqual(pushed, [['push', '/map-projects/new']])
+})
+
+test('handleLinkClick leaves same-page fragments and non-http links to the browser', () => {
+ pushed.length = 0
+ const sameFragment = click(anchor('/map-projects/#section'))
+ const blob = click(anchor('blob:http://localhost:4004/abc'))
+ handleLinkClick(sameFragment)
+ handleLinkClick(blob)
+ assert.equal(sameFragment.defaultPrevented, false)
+ assert.equal(blob.defaultPrevented, false)
+ assert.deepEqual(pushed, [])
+})
diff --git a/src/common/history.js b/src/common/history.js
index 9f8dc50b..38b08dc3 100644
--- a/src/common/history.js
+++ b/src/common/history.js
@@ -9,10 +9,12 @@ export const toRoutePath = path => {
return legacy ? legacy[1] : path
}
+export const isSameSitePath = path => /^\/(?![/\\])/.test(path || '')
+
export const navigate = (path, replace=false) => {
- if(!path)
- return
const routePath = toRoutePath(path)
+ if(!isSameSitePath(routePath))
+ return
if(!appHistory) {
if(replace)
window.location.replace(routePath)
@@ -28,7 +30,7 @@ export const navigate = (path, replace=false) => {
export const legacyHashRoute = (location=window.location) => {
const { hash, search } = location
- if(!hash.startsWith('#/') || /[?&]referrer=/.test(search))
+ if(!hash.startsWith('#/') || /^#\/[/\\]/.test(hash) || /[?&]referrer=/.test(search))
return null
let route = hash.slice(1)
const outer = search.replace(/^\?/, '')
@@ -58,8 +60,12 @@ const appLinkRoute = event => {
const href = anchor.getAttribute('href')
if(!href || (href.startsWith('#') && !href.startsWith('#/')) || /^(mailto|tel|javascript):/i.test(href))
return null
+ if(href.startsWith('#/'))
+ return href.slice(1)
const url = new URL(anchor.href, window.location.href)
- if(url.origin !== window.location.origin)
+ if(url.origin !== window.location.origin || !/^https?:$/.test(url.protocol))
+ return null
+ if(url.hash && !url.hash.startsWith('#/') && url.pathname === window.location.pathname && url.search === window.location.search)
return null
return legacyHashRoute(url) || url.pathname + url.search + url.hash
}
diff --git a/src/common/utils.js b/src/common/utils.js
index cc36e2b5..528f0afa 100644
--- a/src/common/utils.js
+++ b/src/common/utils.js
@@ -15,7 +15,7 @@ import {
import APIService from '../services/APIService';
import GAService from '../services/GAService';
import { SERVER_CONFIGS } from './serverConfigs';
-import { legacyHashRoute } from './history';
+import { legacyHashRoute, isSameSitePath } from './history';
export const currentPath = () => window.location.pathname;
@@ -120,8 +120,6 @@ export const getAPIURL = () => {
return get(savedConfigs, 'url') || window.API_URL || process.env.API_URL;
}
-export const toFullURL = uri => window.location.origin + '/#' + uri;
-
export const toFullAPIURL = uri => getAPIURL() + uri;
export const toRelativeURL = url => url.replace(getAPIURL(), '');
@@ -663,7 +661,7 @@ export const logoutUser = (redirectToLogin, forced) => {
sessionStorage.setItem('session_expired', 'true')
const redirectURL = forced ?
- window.location.origin + '/signin?returnTo=' + encodeURIComponent(returnTo) :
+ window.location.origin + '/#/signin?returnTo=' + encodeURIComponent(returnTo) :
undefined
const logoutURL = getSSOLogoutURL(redirectURL)
@@ -894,7 +892,7 @@ const prepareOAuthReturnTo = returnTo => {
const route = returnToRoute(returnTo)
const path = route && routePath(route)
// The router matches paths case-insensitively, so /SIGNUP would start a sign-up too.
- if(path?.startsWith('/') && !/^\/(oidc\/login|signin|signup)(\/|$)/i.test(path))
+ if(isSameSitePath(path) && !/^\/(oidc\/login|signin|signup)(\/|$)/i.test(path))
sessionStorage.setItem(OAUTH_RETURN_TO_KEY, route)
else
sessionStorage.removeItem(OAUTH_RETURN_TO_KEY)
diff --git a/src/components/app/App.jsx b/src/components/app/App.jsx
index 7ec05fe9..1d917d8e 100644
--- a/src/components/app/App.jsx
+++ b/src/components/app/App.jsx
@@ -87,7 +87,7 @@ const App = props => {
const queryParams = new URLSearchParams(search)
const referrer = queryParams.get('referrer')
if(isLoggedIn()) {
- if(hash && !pathname.startsWith('/oidc/login'))
+ if(hash?.startsWith('#/') && !pathname.startsWith('/oidc/login'))
props.history.replace(pathname + search)
} else if(isOtherOCLClientURL(referrer) && !isLoggedIn()) {
const parts = hash ? hash.split('?') : referrer.split('?')