Skip to content
Open
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
39 changes: 17 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,35 @@
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('/');
} else if((loc.search || '').includes('origin=openmrs')) {
go('/search/?origin=openmrs&type=organizations');
Comment on lines +47 to +48

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Mapper has no /search route, so this branch only ever sent origin=openmrs visitors to a missing page, and since this PR it fires on every path.

Suggested change
} else if((loc.search || '').includes('origin=openmrs')) {
go('/search/?origin=openmrs&type=organizations');

}
})();
</script>
<title></title>
</head>
Expand Down
101 changes: 101 additions & 0 deletions src/common/__tests__/history.test.js
Original file line number Diff line number Diff line change
@@ -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)
})
81 changes: 81 additions & 0 deletions src/common/history.js
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +13 to +15

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same-site paths only. Details in OpenConceptLab/ocl_online#374.

Suggested change
if(!path)
return
const routePath = toRoutePath(path)
const routePath = toRoutePath(path)
// Same-site paths only: '//host' or '/\host' would leave the site, or make pushState throw.
if(!/^\/(?![/\\])/.test(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('#/') || /[?&]referrer=/.test(search))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skip #//… and #/\…. Details in OpenConceptLab/ocl_online#374.

Suggested change
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(/^\?/, '')
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)
Comment on lines +61 to +62

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A relative #/… href resolves against this page, so legacyHashRoute merges this page's query into the route. Also limits routing to http(s) links.

Suggested change
const url = new URL(anchor.href, window.location.href)
if(url.origin !== window.location.origin)
// Resolved against this page, a relative '#/…' link would pick up this page's query.
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
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)
}
36 changes: 24 additions & 12 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 } 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 @@ -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())
Expand Down Expand Up @@ -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) :
Comment on lines 665 to +666

@paynejd paynejd Oct 6, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oclapi2 decodes post_logout_redirect_uri and passes it to Keycloak raw, so Keycloak splits it at each & and the page's query comes back cut at its first &, even if getSSOLogoutURL encodes it (Codex's fix). As a fragment, the pre-PR form, it never reaches oclapi2 or Keycloak, so their redirects carry it through intact; the legacy-hash redirect then turns it into /signin?returnTo=…. Same fix as OpenConceptLab/oclweb3#61, checked there against a mock of both redirects. Keycloak's post-logout URIs then need no change.

Suggested change
const redirectURL = forced ?
window.location.origin + '/#/signin?returnTo=' + encodeURIComponent(returnTo) :
window.location.origin + '/signin?returnTo=' + encodeURIComponent(returnTo) :
// A fragment never reaches oclapi2 or Keycloak, so it survives their redirects intact (a query is decoded and
// split on '&'). Back in the app, the legacy-hash redirect turns it into /signin?returnTo=…
const redirectURL = forced ?
window.location.origin + '/#/signin?returnTo=' + encodeURIComponent(returnTo) :

undefined
const logoutURL = getSSOLogoutURL(redirectURL)

Expand All @@ -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('/');
}


Expand Down Expand Up @@ -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))
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 && !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
2 changes: 1 addition & 1 deletion src/components/app/LeftMenu.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ const LeftMenu = ({ isOpen, onClose }) => {
px: 2,
borderRadius: '100px'
}}
href='/#/'
href='/'
className='no-anchor-styles'
selected={location.pathname === '/'}
>
Expand Down
2 changes: 1 addition & 1 deletion src/components/common/BaseEntityChip.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion src/components/common/OCLLogo.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { PRIMARY_COLORS } from '../../common/colors'

const OCLLogo = ({ color, width, height, }) => {
return (
<a className='hover-no-bg-color flex-vertical-center' href='/#/'>
<a className='hover-no-bg-color flex-vertical-center' href='/'>
<SvgIcon sx={{color: 'primary.main', fill: color || PRIMARY_COLORS.main, width: width || '84px', height: height || '28px'}} href='/'>
<svg version="1.1" id="Layer_1" x="0px" y="0px" viewBox="0 0 306.14 111.63">
<g>
Expand Down
2 changes: 1 addition & 1 deletion src/components/concepts/ConceptDetails.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ const ConceptDetails = ({ concept, repo, mappings, reverseMappings, loading, loa
{t('common.last_updated')} {
loading ?
<Skeleton variant='text' width='40%' sx={{marginLeft: '8px', fontSize: '12px', display: 'inline-block'}} />:
<>{formatDateTime(concept.versioned_updated_on || concept.updated_on)} {t('common.by')} <Link sx={{fontSize: '12px', justifyContent: 'flex-start'}} href={`#/users/${updatedBy}`} label={updatedBy} /></>
<>{formatDateTime(concept.versioned_updated_on || concept.updated_on)} {t('common.by')} <Link sx={{fontSize: '12px', justifyContent: 'flex-start'}} href={`/users/${updatedBy}`} label={updatedBy} /></>
}
</Typography>
<Typography component='span' sx={{display: 'inline-block', padding: 0, fontSize: '12px', color: 'surface.contrastText', width: '100%'}}>
Expand Down
2 changes: 1 addition & 1 deletion src/components/errors/Error403.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ const Error403 = () => {
i18nKey='common.go_back_or_to_your_dashboard'
components={[
<Link key='back' sx={{minWidth: 'auto', fontSize: '16px', padding: '0 4px'}} label={t('common.back')} onClick={() => history.goBack()} />,
<Link key='dashboard' sx={{minWidth: 'auto', fontSize: '16px', paddingLeft: '4px'}} label={t('dashboard.name')} href='/#/' />,
<Link key='dashboard' sx={{minWidth: 'auto', fontSize: '16px', paddingLeft: '4px'}} label={t('dashboard.name')} href='/' />,
]}
/>
</p>
Expand Down
Loading
Loading