Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 15 additions & 22 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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/
-->
<link rel="shortcut icon" href="./favicon.ico">
<link rel="shortcut icon" href="/favicon.ico">
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Expand All @@ -19,40 +19,33 @@
Learn how to configure a non-root public URL by running `npm run build`.
-->
<!-- CSS -->
<link rel="stylesheet" href="bootstrap.min.css">
<link rel="stylesheet" href="/bootstrap.min.css">
<!-- Font Awesome 6/7 full bundle -->
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css"
/>
<link rel="stylesheet" href="fontello/css/fontello.css">
<link rel="stylesheet" href="/fontello/css/fontello.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto&display=swap" rel="stylesheet">

<!-- ENV config -->
<script src="./env-config.js"></script>
<script src="/env-config.js"></script>
<script src="https://cdn.jsdelivr.net/npm/mark.js"></script>
<script>
if(window.location.pathname.startsWith('/search')) {
window.location = '/#/search/' + window.location.search;
}
if(window.location.hash.startsWith('#/state=')) {
window.location = '/#/oidc/login/' + window.location.hash.replace('#/', '?') + '&next=' + window.location.pathname;
}
if(window.location.hash.startsWith('#state=')) {
window.location = '/#/oidc/login/' + window.location.hash.replace('#', '?') + '&next=' + window.location.pathname;
}
if(!window.location.hash && (window.location.search || '').includes('code=')) {
window.location = '/#/oidc/login/' + window.location.search + '&next=' + window.location.pathname;
}
if(window.location.href.includes('/?session_state=')) {
window.location = '/';
}
if((window.location.search || '').includes('origin=openmrs')) {
window.location = '/#/search/?origin=openmrs&type=organizations'
}
(function() {
var loc = window.location;
var go = function(path) { window.history.replaceState(null, '', path); };
if(loc.hash.startsWith('#/state=') || loc.hash.startsWith('#state=')) {
go('/oidc/login/?' + loc.hash.replace(/^#\/?/, '') + '&next=' + loc.pathname);
} else if(loc.pathname === '/' && !loc.hash && (loc.search || '').includes('code=')) {
go('/oidc/login/' + loc.search + '&next=' + loc.pathname);
} else if(loc.href.includes('/?session_state=')) {
go('/');
}
})();
</script>
<title></title>
</head>
Expand Down
136 changes: 136 additions & 0 deletions src/common/__tests__/history.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/* 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)
})

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, [])
})
87 changes: 87 additions & 0 deletions src/common/history.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
let appHistory = null

export const setAppHistory = history => {
appHistory = history
}

export const toRoutePath = path => {
const legacy = path?.match(/^\/?#(\/.*)$/)
return legacy ? legacy[1] : path
}

export const isSameSitePath = path => /^\/(?![/\\])/.test(path || '')

export const navigate = (path, replace=false) => {
const routePath = toRoutePath(path)
if(!isSameSitePath(routePath))
return
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('#/') || /^#\/[/\\]/.test(hash) || /[?&]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
if(href.startsWith('#/'))
return href.slice(1)
const url = new URL(anchor.href, window.location.href)
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
}

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)
}
38 changes: 24 additions & 14 deletions src/common/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@ import {
import APIService from '../services/APIService';
import GAService from '../services/GAService';
import { SERVER_CONFIGS } from './serverConfigs';
import { legacyHashRoute, isSameSitePath } 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);
Expand Down Expand Up @@ -119,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(), '');
Expand Down Expand Up @@ -210,7 +209,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())
Expand Down Expand Up @@ -657,7 +656,7 @@ 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')

Expand All @@ -671,10 +670,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('/');
}


Expand Down Expand Up @@ -877,12 +874,25 @@ 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))
if(isSameSitePath(path) && !/^\/(oidc\/login|signin|signup)(\/|$)/i.test(path))
sessionStorage.setItem(OAUTH_RETURN_TO_KEY, route)
else
sessionStorage.removeItem(OAUTH_RETURN_TO_KEY)
Expand Down
7 changes: 3 additions & 4 deletions src/components/app/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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?.startsWith('#/') && !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 })
}
}
}
Expand Down
Loading
Loading