feat: Support for OpenID Connect (#2431)

* feat: Add OpenID Connect support, speed up docker rebuilds

* fix: Switch Google Cloud API URL to v1

* chore: Remove redux-oidc and use our own components instead
This commit is contained in:
Erik Ziegler 2021-06-23 20:41:27 +02:00 committed by GitHub
parent 5643f8f6d2
commit e7b32cc51e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 2746 additions and 2109 deletions

View File

@ -21,7 +21,7 @@ if [ -n "$CLIENT_ID" ] || [ -n "$HEALTHCARE_API_ENDPOINT" ]
echo "Updating config..." echo "Updating config..."
# - Use SED to replace the HEALTHCARE_API_ENDPOINT that is currently in google.js # - Use SED to replace the HEALTHCARE_API_ENDPOINT that is currently in google.js
sed -i -e "s+https://healthcare.googleapis.com/v1beta1+$HEALTHCARE_API_ENDPOINT+g" /usr/share/nginx/html/google.js sed -i -e "s+https://healthcare.googleapis.com/v1+$HEALTHCARE_API_ENDPOINT+g" /usr/share/nginx/html/google.js
fi fi
# - Copy google.js to overwrite app-config.js # - Copy google.js to overwrite app-config.js

View File

@ -26,6 +26,7 @@
"autoFix": true "autoFix": true
} }
], ],
"jest.autoRun": "off",
"prettier.disableLanguages": ["html"], "prettier.disableLanguages": ["html"],
"prettier.endOfLine": "lf", "prettier.endOfLine": "lf",
"workbench.colorCustomizations": {}, "workbench.colorCustomizations": {},

View File

@ -101,7 +101,7 @@ module.exports = (env, argv, { SRC_DIR, DIST_DIR }) => {
), ),
}), }),
new UnusedFilesWebpackPlugin({ new UnusedFilesWebpackPlugin({
failOnUnused: true, failOnUnused: false,
globOptions: { globOptions: {
ignore: [ ignore: [
"node_modules/**/*", "node_modules/**/*",

View File

@ -21,28 +21,37 @@
# Stage 1: Build the application # Stage 1: Build the application
# docker build -t ohif/viewer:latest . # docker build -t ohif/viewer:latest .
FROM node:14.3.0-slim as builder FROM node:14.3.0-slim as json-copier
RUN mkdir /usr/src/app RUN mkdir /usr/src/app
WORKDIR /usr/src/app WORKDIR /usr/src/app
# Copy Files
COPY .docker /usr/src/app/.docker COPY ["package.json", "yarn.lock", "./"]
COPY .webpack /usr/src/app/.webpack
COPY extensions /usr/src/app/extensions COPY extensions /usr/src/app/extensions
COPY modes /usr/src/app/modes COPY modes /usr/src/app/modes
COPY platform /usr/src/app/platform COPY platform /usr/src/app/platform
COPY .browserslistrc /usr/src/app/.browserslistrc
COPY aliases.config.js /usr/src/app/aliases.config.js # Find and remove non-package.json files
COPY babel.config.js /usr/src/app/babel.config.js RUN find extensions \! -name "package.json" -mindepth 2 -maxdepth 2 -print | xargs rm -rf
COPY lerna.json /usr/src/app/lerna.json RUN find modes \! -name "package.json" -mindepth 2 -maxdepth 2 -print | xargs rm -rf
COPY package.json /usr/src/app/package.json RUN find platform \! -name "package.json" -mindepth 2 -maxdepth 2 -print | xargs rm -rf
COPY postcss.config.js /usr/src/app/postcss.config.js
COPY yarn.lock /usr/src/app/yarn.lock # Copy Files
FROM node:14.3.0-slim as builder
RUN mkdir /usr/src/app
WORKDIR /usr/src/app
COPY --from=json-copier /usr/src/app .
# Run the install before copying the rest of the files # Run the install before copying the rest of the files
RUN yarn config set workspaces-experimental true RUN yarn config set workspaces-experimental true
RUN yarn install RUN yarn install --frozen-lockfile
COPY . .
# To restore workspaces symlinks
RUN yarn install --frozen-lockfile
ENV PATH /usr/src/app/node_modules/.bin:$PATH ENV PATH /usr/src/app/node_modules/.bin:$PATH
ENV QUICK_BUILD true ENV QUICK_BUILD true
@ -51,7 +60,7 @@ ENV QUICK_BUILD true
RUN yarn run build RUN yarn run build
# Stage 2: Bundle the built application into a Docker container # Stage 3: Bundle the built application into a Docker container
# which runs Nginx using Alpine Linux # which runs Nginx using Alpine Linux
FROM nginx:1.15.5-alpine FROM nginx:1.15.5-alpine
RUN apk add --no-cache bash RUN apk add --no-cache bash

View File

@ -255,8 +255,7 @@ following resources helpful:
We chose to use a generic OpenID Connect library on the client, but it's worth We chose to use a generic OpenID Connect library on the client, but it's worth
noting that Keycloak comes packaged with its own: noting that Keycloak comes packaged with its own:
- [redux-oidc](https://github.com/maxmantz/redux-oidc) (Which wraps - [oidc-client-js](https://github.com/IdentityModel/oidc-client-js/wiki)
[oidc-client-js](https://github.com/IdentityModel/oidc-client-js/wiki))
- [Keycloak JavaScript Adapter](https://www.keycloak.org/docs/latest/securing_apps/index.html#_javascript_adapter) - [Keycloak JavaScript Adapter](https://www.keycloak.org/docs/latest/securing_apps/index.html#_javascript_adapter)
If you're not already drowning in links, here are some good security resources If you're not already drowning in links, here are some good security resources

View File

@ -57,7 +57,7 @@ function ExampleContextProvider({ children }) {
return ( return (
<ExampleContext.Provider value={{ example: 'value' }}> <ExampleContext.Provider value={{ example: 'value' }}>
{children} {children}
</TrackedMeasurementsContext.Provider> </ExampleContext.Provider>
); );
} }

View File

@ -63,9 +63,8 @@ All use the `ACTIVE_VIEWPORT::CORNERSTONE` context.
## Viewport Module ## Viewport Module
Our Viewport wraps [cornerstonejs/react-cornerstone-viewport][react-viewport] Our Viewport wraps [cornerstonejs/react-cornerstone-viewport][react-viewport].
and is connected the redux store. This module is the most prone to change as we This module is the most prone to change as we hammer out our Viewport interface.
hammer out our Viewport interface.
## Tool Configuration ## Tool Configuration
@ -87,10 +86,12 @@ Tools can be configured through extension configuration using the tools key:
## Annotate Tools Configuration ## Annotate Tools Configuration
*We currently support one property for annotation tools.* _We currently support one property for annotation tools._
### Hide handles ### Hide handles
This extension configuration allows you to toggle on/off handle rendering for all annotate tools:
This extension configuration allows you to toggle on/off handle rendering for
all annotate tools:
```js ```js
... ...
@ -119,3 +120,4 @@ This extension configuration allows you to toggle on/off handle rendering for al
[cornerstone-tools]: https://github.com/cornerstonejs/cornerstoneTools [cornerstone-tools]: https://github.com/cornerstonejs/cornerstoneTools
[cornerstone]: https://github.com/cornerstonejs/cornerstone [cornerstone]: https://github.com/cornerstonejs/cornerstone
<!-- prettier-ignore-end --> <!-- prettier-ignore-end -->
```

View File

@ -5,7 +5,7 @@ import csTools from 'cornerstone-tools';
import merge from 'lodash.merge'; import merge from 'lodash.merge';
import getTools, { toolsGroupedByType } from './utils/getTools.js'; import getTools, { toolsGroupedByType } from './utils/getTools.js';
import initCornerstoneTools from './initCornerstoneTools.js'; import initCornerstoneTools from './initCornerstoneTools.js';
import './initWADOImageLoader.js'; import initWADOImageLoader from './initWADOImageLoader.js';
import getCornerstoneMeasurementById from './utils/getCornerstoneMeasurementById'; import getCornerstoneMeasurementById from './utils/getCornerstoneMeasurementById';
import measurementServiceMappingsFactory from './utils/measurementServiceMappings/measurementServiceMappingsFactory'; import measurementServiceMappingsFactory from './utils/measurementServiceMappings/measurementServiceMappingsFactory';
import { setEnabledElement } from './state'; import { setEnabledElement } from './state';
@ -69,6 +69,7 @@ export default function init({
MeasurementService, MeasurementService,
DisplaySetService, DisplaySetService,
ToolBarService, ToolBarService,
UserAuthenticationService,
} = servicesManager.services; } = servicesManager.services;
const tools = getTools(); const tools = getTools();
@ -275,6 +276,8 @@ export default function init({
cs.metaData.addProvider(metadataProvider.get.bind(metadataProvider), 9999); cs.metaData.addProvider(metadataProvider.get.bind(metadataProvider), 9999);
initWADOImageLoader(UserAuthenticationService);
// ~~ // ~~
const defaultCsToolsConfig = csToolsConfig || { const defaultCsToolsConfig = csToolsConfig || {
globalToolSyncEnabled: false, // hold on to your pants! globalToolSyncEnabled: false, // hold on to your pants!

View File

@ -2,17 +2,40 @@ import cornerstone from 'cornerstone-core';
import cornerstoneWADOImageLoader from 'cornerstone-wado-image-loader'; import cornerstoneWADOImageLoader from 'cornerstone-wado-image-loader';
import dicomParser from 'dicom-parser'; import dicomParser from 'dicom-parser';
//import { initWebWorkers } from './utils/index.js'; let initialized = false;
cornerstoneWADOImageLoader.external.cornerstone = cornerstone; function initWebWorkers() {
cornerstoneWADOImageLoader.external.dicomParser = dicomParser; const config = {
maxWebWorkers: Math.max(navigator.hardwareConcurrency - 1, 1),
startWebWorkersOnDemand: true,
taskConfiguration: {
decodeTask: {
initializeCodecsOnStartup: false,
usePDFJS: false,
strict: false,
},
},
};
cornerstoneWADOImageLoader.configure({ if (!initialized) {
beforeSend: function(xhr) { cornerstoneWADOImageLoader.webWorkerManager.initialize(config);
/*const headers = OHIF.DICOMWeb.getAuthorizationHeader(); initialized = true;
}
}
if (headers.Authorization) { export default function initWADOImageLoader(UserAuthenticationService) {
xhr.setRequestHeader('Authorization', headers.Authorization); cornerstoneWADOImageLoader.external.cornerstone = cornerstone;
}*/ cornerstoneWADOImageLoader.external.dicomParser = dicomParser;
},
}); cornerstoneWADOImageLoader.configure({
beforeSend: function(xhr) {
const headers = UserAuthenticationService.getAuthorizationHeader();
if (headers && headers.Authorization) {
xhr.setRequestHeader('Authorization', headers.Authorization);
}
},
});
initWebWorkers();
}

View File

@ -1,22 +0,0 @@
import { redux } from '@ohif/core';
const { setLayout } = redux.actions;
/**
* Update the current layout with a simple Cornerstone one
*
* @return void
*/
const setCornerstoneLayout = () => {
const layout = {
numRows: 1,
numColumns: 1,
viewports: [{ plugin: 'cornerstone' }],
};
const action = setLayout(layout);
window.store.dispatch(action);
};
export default setCornerstoneLayout;

View File

@ -38,7 +38,7 @@ const EXPLICIT_VR_LITTLE_ENDIAN = '1.2.840.10008.1.2.1';
* @param {bool} supportsReject - Whether the server supports reject calls (i.e. DCM4CHEE) * @param {bool} supportsReject - Whether the server supports reject calls (i.e. DCM4CHEE)
* @param {bool} lazyLoadStudy - "enableStudyLazyLoad"; Request series meta async instead of blocking * @param {bool} lazyLoadStudy - "enableStudyLazyLoad"; Request series meta async instead of blocking
*/ */
function createDicomWebApi(dicomWebConfig) { function createDicomWebApi(dicomWebConfig, UserAuthenticationService) {
const { const {
qidoRoot, qidoRoot,
wadoRoot, wadoRoot,
@ -46,15 +46,17 @@ function createDicomWebApi(dicomWebConfig) {
supportsFuzzyMatching, supportsFuzzyMatching,
supportsWildcard, supportsWildcard,
supportsReject, supportsReject,
requestOptions,
} = dicomWebConfig; } = dicomWebConfig;
const qidoConfig = { const qidoConfig = {
url: qidoRoot, url: qidoRoot,
// headers: DICOMWeb.getAuthorizationHeader(server), headers: UserAuthenticationService.getAuthorizationHeader(),
}; };
const wadoConfig = { const wadoConfig = {
url: wadoRoot, url: wadoRoot,
headers: UserAuthenticationService.getAuthorizationHeader(),
}; };
// TODO -> Two clients sucks, but its better than 1000. // TODO -> Two clients sucks, but its better than 1000.
@ -78,7 +80,12 @@ function createDicomWebApi(dicomWebConfig) {
query: { query: {
studies: { studies: {
mapParams: mapParams.bind(), mapParams: mapParams.bind(),
search: async function (origParams) { search: async function(origParams) {
const headers = UserAuthenticationService.getAuthorizationHeader();
if (headers) {
qidoDicomWebClient.headers = headers;
}
const { studyInstanceUid, seriesInstanceUid, ...mappedParams } = const { studyInstanceUid, seriesInstanceUid, ...mappedParams } =
mapParams(origParams, { mapParams(origParams, {
supportsFuzzyMatching, supportsFuzzyMatching,
@ -98,7 +105,12 @@ function createDicomWebApi(dicomWebConfig) {
}, },
series: { series: {
// mapParams: mapParams.bind(), // mapParams: mapParams.bind(),
search: async function (studyInstanceUid) { search: async function(studyInstanceUid) {
const headers = UserAuthenticationService.getAuthorizationHeader();
if (headers) {
qidoDicomWebClient.headers = headers;
}
const results = await seriesInStudy( const results = await seriesInStudy(
qidoDicomWebClient, qidoDicomWebClient,
studyInstanceUid studyInstanceUid
@ -109,14 +121,20 @@ function createDicomWebApi(dicomWebConfig) {
// processResults: processResults.bind(), // processResults: processResults.bind(),
}, },
instances: { instances: {
search: (studyInstanceUid, queryParameters) => search: (studyInstanceUid, queryParameters) => {
const headers = UserAuthenticationService.getAuthorizationHeader();
if (headers) {
qidoDicomWebClient.headers = headers;
}
qidoSearch.call( qidoSearch.call(
undefined, undefined,
qidoDicomWebClient, qidoDicomWebClient,
studyInstanceUid, studyInstanceUid,
null, null,
queryParameters queryParameters
), );
},
}, },
}, },
retrieve: { retrieve: {
@ -125,6 +143,11 @@ function createDicomWebApi(dicomWebConfig) {
// Conduct query, return a promise like others // Conduct query, return a promise like others
// Await this call and add to DicomMetadataStore after receiving result // Await this call and add to DicomMetadataStore after receiving result
metadata: (queryParams, callback) => { metadata: (queryParams, callback) => {
const headers = UserAuthenticationService.getAuthorizationHeader();
if (headers) {
wadoDicomWebClient.headers = headers;
}
let { StudyInstanceUIDs } = urlUtil.parse(queryParams, true); let { StudyInstanceUIDs } = urlUtil.parse(queryParams, true);
StudyInstanceUIDs = urlUtil.paramString.parseParam(StudyInstanceUIDs); StudyInstanceUIDs = urlUtil.paramString.parseParam(StudyInstanceUIDs);
@ -164,6 +187,11 @@ function createDicomWebApi(dicomWebConfig) {
}, },
store: { store: {
dicom: async dataset => { dicom: async dataset => {
const headers = UserAuthenticationService.getAuthorizationHeader();
if (headers) {
wadoDicomWebClient.headers = headers;
}
const meta = { const meta = {
FileMetaInformationVersion: FileMetaInformationVersion:
dataset._meta.FileMetaInformationVersion.Value, dataset._meta.FileMetaInformationVersion.Value,
@ -196,6 +224,11 @@ function createDicomWebApi(dicomWebConfig) {
sortFunction, sortFunction,
madeInClient = false, madeInClient = false,
} = {}) => { } = {}) => {
const headers = UserAuthenticationService.getAuthorizationHeader();
if (headers) {
wadoDicomWebClient.headers = headers;
}
if (!StudyInstanceUID) { if (!StudyInstanceUID) {
throw new Error( throw new Error(
'Unable to query for SeriesMetadata without StudyInstanceUID' 'Unable to query for SeriesMetadata without StudyInstanceUID'

View File

@ -2,7 +2,9 @@ async function getStudiesForPatientByStudyInstanceUID(
dataSource, dataSource,
StudyInstanceUID StudyInstanceUID
) { ) {
if (StudyInstanceUID === undefined) return; if (StudyInstanceUID === undefined) {
return;
}
// TODO: The `DicomMetadataStore` should short-circuit both of these requests // TODO: The `DicomMetadataStore` should short-circuit both of these requests
// Data _could_ be here from route query, or if using JSON data source // Data _could_ be here from route query, or if using JSON data source
// We could also force this to "await" these values being available in the DICOMStore? // We could also force this to "await" these values being available in the DICOMStore?

View File

@ -1,37 +0,0 @@
import React, { useState } from 'react';
const HelloWorldContext = React.createContext({
message: 'HelloWorldContextTesting',
setMessage: () => { },
});
HelloWorldContext.displayName = 'HelloWorldContext';
function HelloWorldContextProvider({ children }) {
const [message, setMessage] = useState('HelloWorldContextTesting');
return (
<HelloWorldContext.Provider
value={{
message,
setMessage,
}}
>
{children}
</HelloWorldContext.Provider>
);
}
function getContextModule() {
return [
{
name: 'HelloWorldContext',
context: HelloWorldContext,
provider: HelloWorldContextProvider,
},
];
}
export { HelloWorldContext };
export default getContextModule;

View File

@ -1,4 +1,3 @@
import getContextModule from './getContextModule.js';
import getDataSourcesModule from './getDataSourcesModule.js'; import getDataSourcesModule from './getDataSourcesModule.js';
import getLayoutTemplateModule from './getLayoutTemplateModule.js'; import getLayoutTemplateModule from './getLayoutTemplateModule.js';
import getPanelModule from './getPanelModule.js'; import getPanelModule from './getPanelModule.js';
@ -13,7 +12,6 @@ export default {
* Only required property. Should be a unique value across all extensions. * Only required property. Should be a unique value across all extensions.
*/ */
id, id,
getContextModule,
getDataSourcesModule, getDataSourcesModule,
getHangingProtocolModule, getHangingProtocolModule,
getLayoutTemplateModule, getLayoutTemplateModule,

View File

@ -2,7 +2,9 @@ async function getStudiesForPatientByStudyInstanceUID(
dataSource, dataSource,
StudyInstanceUID StudyInstanceUID
) { ) {
if (StudyInstanceUID === undefined) return; if (StudyInstanceUID === undefined) {
return;
}
// TODO: The `DicomMetadataStore` should short-circuit both of these requests // TODO: The `DicomMetadataStore` should short-circuit both of these requests
// Data _could_ be here from route query, or if using JSON data source // Data _could_ be here from route query, or if using JSON data source
// We could also force this to "await" these values being available in the DICOMStore? // We could also force this to "await" these values being available in the DICOMStore?

View File

@ -43,3 +43,7 @@
no-cache, no-cache,
no-store, no-store,
must-revalidate''' must-revalidate'''
# COMMENT: For sharedArrayBuffer, see https://developer.chrome.com/blog/enabling-shared-array-buffer/
Cross-Origin-Embedder-Policy = "require-corp"
Cross-Origin-Opener-Policy = "same-origin"

View File

@ -41,6 +41,7 @@
"dicomweb-client": "^0.6.0", "dicomweb-client": "^0.6.0",
"isomorphic-base64": "^1.0.2", "isomorphic-base64": "^1.0.2",
"lodash.merge": "^4.6.1", "lodash.merge": "^4.6.1",
"lodash.clonedeep": "^4.5.0",
"moment": "^2.24.0", "moment": "^2.24.0",
"mousetrap": "^1.6.3", "mousetrap": "^1.6.3",
"query-string": "^6.14.0", "query-string": "^6.14.0",

View File

@ -10,7 +10,7 @@ import user from '../user';
* @param {string|function} [server.requestOptions.auth] * @param {string|function} [server.requestOptions.auth]
* @returns {Object} { Authorization } * @returns {Object} { Authorization }
*/ */
export default function getAuthorizationHeader({ requestOptions } = {}) { export default function getAuthorizationHeader({ requestOptions } = {}, user) {
const headers = {}; const headers = {};
// Check for OHIF.user since this can also be run on the server // Check for OHIF.user since this can also be run on the server

View File

@ -64,11 +64,11 @@ describe('getAuthorizationHeader', () => {
it('should return an Authorization with accessToken when server is not defined and there is an accessToken', () => { it('should return an Authorization with accessToken when server is not defined and there is an accessToken', () => {
user.getAccessToken.mockImplementationOnce(() => 'MOCKED_TOKEN'); user.getAccessToken.mockImplementationOnce(() => 'MOCKED_TOKEN');
const authentication = getAuthorizationHeader({}); const authentication = getAuthorizationHeader({}, user);
const exptecteHeaderBasedOnUserAccessToekn = { const expectedHeaderBasedOnUserAccessToken = {
Authorization: 'Bearer MOCKED_TOKEN', Authorization: 'Bearer MOCKED_TOKEN',
}; };
expect(authentication).toEqual(exptecteHeaderBasedOnUserAccessToekn); expect(authentication).toEqual(expectedHeaderBasedOnUserAccessToken);
}); });
}); });

View File

@ -439,7 +439,7 @@ class MetadataProvider {
_getUIDsFromImageID(imageId) { _getUIDsFromImageID(imageId) {
if (imageId.includes('wadors:')) { if (imageId.includes('wadors:')) {
const strippedImageId = imageId.split('studies/')[1]; const strippedImageId = imageId.split('/studies/')[1];
const splitImageId = strippedImageId.split('/'); const splitImageId = strippedImageId.split('/');
return { return {

View File

@ -277,13 +277,16 @@ export default class ExtensionManager {
}; };
_initDataSourcesModule(extensionModule, extensionId, dataSources = []) { _initDataSourcesModule(extensionModule, extensionId, dataSources = []) {
const { UserAuthenticationService } = this._servicesManager.services;
extensionModule.forEach(element => { extensionModule.forEach(element => {
const namespace = `${extensionId}.${MODULE_TYPES.DATA_SOURCE}.${element.name}`; const namespace = `${extensionId}.${MODULE_TYPES.DATA_SOURCE}.${element.name}`;
dataSources.forEach(dataSource => { dataSources.forEach(dataSource => {
if (dataSource.namespace === namespace) { if (dataSource.namespace === namespace) {
const dataSourceInstance = element.createDataSource( const dataSourceInstance = element.createDataSource(
dataSource.configuration dataSource.configuration,
UserAuthenticationService
); );
if (this.dataSourceMap[dataSource.sourceName]) { if (this.dataSourceMap[dataSource.sourceName]) {

View File

@ -15,6 +15,10 @@ describe('ExtensionManager.js', () => {
}; };
servicesManager = { servicesManager = {
registerService: jest.fn(), registerService: jest.fn(),
services: {
// Required for DataSource Module initiation
UserAuthenticationService: jest.fn(),
},
}; };
appConfig = { appConfig = {
testing: true, testing: true,
@ -100,7 +104,6 @@ describe('ExtensionManager.js', () => {
extensionManager.registerExtension(undefinedExtension); extensionManager.registerExtension(undefinedExtension);
}).toThrow('Attempting to register a null/undefined extension.'); }).toThrow('Attempting to register a null/undefined extension.');
expect(() => { expect(() => {
extensionManager.registerExtension(nullExtension); extensionManager.registerExtension(nullExtension);
}).toThrow('Attempting to register a null/undefined extension.'); }).toThrow('Attempting to register a null/undefined extension.');
@ -185,7 +188,7 @@ describe('ExtensionManager.js', () => {
hotkeysManager: undefined, hotkeysManager: undefined,
appConfig, appConfig,
configuration: extensionConfiguration, configuration: extensionConfiguration,
extensionManager extensionManager,
}); });
} }
}); });
@ -220,7 +223,7 @@ describe('ExtensionManager.js', () => {
}, },
getContextModule: () => { getContextModule: () => {
return [{}]; return [{}];
} },
}; };
extensionManager.registerExtension(extension); extensionManager.registerExtension(extension);

View File

@ -26,6 +26,7 @@ import {
ViewportGridService, ViewportGridService,
HangingProtocolService, HangingProtocolService,
pubSubServiceInterface, pubSubServiceInterface,
UserAuthenticationService,
} from './services'; } from './services';
import IWebApiDataSource from './DataSources/IWebApiDataSource'; import IWebApiDataSource from './DataSources/IWebApiDataSource';
@ -66,6 +67,7 @@ const OHIF = {
ToolBarService, // TODO: TYPO ToolBarService, // TODO: TYPO
ViewportGridService, ViewportGridService,
HangingProtocolService, HangingProtocolService,
UserAuthenticationService,
IWebApiDataSource, IWebApiDataSource,
DicomMetadataStore, DicomMetadataStore,
pubSubServiceInterface, pubSubServiceInterface,
@ -101,6 +103,7 @@ export {
ToolBarService, ToolBarService,
ViewportGridService, ViewportGridService,
HangingProtocolService, HangingProtocolService,
UserAuthenticationService,
IWebApiDataSource, IWebApiDataSource,
DicomMetadataStore, DicomMetadataStore,
pubSubServiceInterface, pubSubServiceInterface,

View File

@ -34,9 +34,10 @@ describe('Top level exports', () => {
'ToolBarService', 'ToolBarService',
'ViewportGridService', 'ViewportGridService',
'HangingProtocolService', 'HangingProtocolService',
'UserAuthenticationService',
'IWebApiDataSource', 'IWebApiDataSource',
'DicomMetadataStore', 'DicomMetadataStore',
'pubSubServiceInterface' 'pubSubServiceInterface',
].sort(); ].sort();
const exports = Object.keys(OHIF).sort(); const exports = Object.keys(OHIF).sort();

View File

@ -1,4 +1,4 @@
import { cloneDeep } from 'lodash'; import cloneDeep from 'lodash.clonedeep';
import pubSubServiceInterface from '../_shared/pubSubServiceInterface'; import pubSubServiceInterface from '../_shared/pubSubServiceInterface';
import sortBy from '../../utils/sortBy.js'; import sortBy from '../../utils/sortBy.js';
import ProtocolEngine from './ProtocolEngine'; import ProtocolEngine from './ProtocolEngine';

View File

@ -0,0 +1,94 @@
const name = 'UserAuthenticationService';
const publicAPI = {
name,
getState: _getState,
setUser: _setUser,
getUser: _getUser,
getAuthorizationHeader: _getAuthorizationHeader,
handleUnauthenticated: _handleUnauthenticated,
setServiceImplementation,
reset: _reset,
set: _set,
};
const serviceImplementation = {
_getState: () => console.warn('getState() NOT IMPLEMENTED'),
_setUser: () => console.warn('_setUser() NOT IMPLEMENTED'),
_getUser: () => console.warn('_setUser() NOT IMPLEMENTED'),
_getAuthorizationHeader: () =>
console.warn('_getAuthorizationHeader() NOT IMPLEMENTED'),
_handleUnauthenticated: () =>
console.warn('_handleUnauthenticated() NOT IMPLEMENTED'),
_reset: () => console.warn('reset() NOT IMPLEMENTED'),
_set: () => console.warn('set() NOT IMPLEMENTED'),
};
function _getState() {
return serviceImplementation._getState();
}
function _setUser(user) {
return serviceImplementation._setUser(user);
}
function _getUser() {
return serviceImplementation._getUser();
}
function _getAuthorizationHeader() {
const user = serviceImplementation._getUser();
return serviceImplementation._getAuthorizationHeader(user);
}
function _handleUnauthenticated() {
return serviceImplementation._handleUnauthenticated();
}
function _set(state) {
return serviceImplementation._set(state);
}
function _reset() {
return serviceImplementation._reset({});
}
function setServiceImplementation({
getState: getStateImplementation,
setUser: setUserImplementation,
getUser: getUserImplementation,
getAuthorizationHeader: getAuthorizationHeaderImplementation,
handleUnauthenticated: handleUnauthenticatedImplementation,
reset: resetImplementation,
set: setImplementation,
}) {
if (getStateImplementation) {
serviceImplementation._getState = getStateImplementation;
}
if (setUserImplementation) {
serviceImplementation._setUser = setUserImplementation;
}
if (getUserImplementation) {
serviceImplementation._getUser = getUserImplementation;
}
if (getAuthorizationHeaderImplementation) {
serviceImplementation._getAuthorizationHeader = getAuthorizationHeaderImplementation;
}
if (handleUnauthenticatedImplementation) {
serviceImplementation._handleUnauthenticated = handleUnauthenticatedImplementation;
}
if (resetImplementation) {
serviceImplementation._reset = resetImplementation;
}
if (setImplementation) {
serviceImplementation._set = setImplementation;
}
}
export default {
name,
create: ({ configuration = {} }) => {
return publicAPI;
},
};

View File

@ -0,0 +1,3 @@
import UserAuthenticationService from './UserAuthenticationService';
export default UserAuthenticationService;

View File

@ -11,6 +11,7 @@ import ViewportGridService from './ViewportGridService';
import CineService from './CineService'; import CineService from './CineService';
import HangingProtocolService from './HangingProtocolService'; import HangingProtocolService from './HangingProtocolService';
import pubSubServiceInterface from './_shared/pubSubServiceInterface'; import pubSubServiceInterface from './_shared/pubSubServiceInterface';
import UserAuthenticationService from './UserAuthenticationService';
export { export {
MeasurementService, MeasurementService,
@ -26,4 +27,5 @@ export {
HangingProtocolService, HangingProtocolService,
CineService, CineService,
pubSubServiceInterface, pubSubServiceInterface,
UserAuthenticationService,
}; };

View File

@ -25,6 +25,9 @@ export {
ViewportGridContext, ViewportGridContext,
ViewportGridProvider, ViewportGridProvider,
useViewportGrid, useViewportGrid,
UserAuthenticationContext,
UserAuthenticationProvider,
useUserAuthentication,
} from './src/contextProviders'; } from './src/contextProviders';
/** COMPONENTS */ /** COMPONENTS */
@ -92,7 +95,7 @@ export {
ViewportDownloadForm, ViewportDownloadForm,
ViewportGrid, ViewportGrid,
ViewportPane, ViewportPane,
WindowLevelMenuItem WindowLevelMenuItem,
} from './src/components'; } from './src/components';
/** VIEWS */ /** VIEWS */

View File

@ -40,7 +40,7 @@
"react-dnd-html5-backend": "10.0.2", "react-dnd-html5-backend": "10.0.2",
"react-dom": "16.11.0", "react-dom": "16.11.0",
"react-draggable": "4.4.3", "react-draggable": "4.4.3",
"react-error-boundary": "2.2.x", "react-error-boundary": "^3.1.3",
"react-modal": "3.11.2", "react-modal": "3.11.2",
"react-outside-click-handler": "^1.3.0", "react-outside-click-handler": "^1.3.0",
"react-select": "3.0.8", "react-select": "3.0.8",

View File

@ -3,18 +3,18 @@ import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import classNames from 'classnames'; import classNames from 'classnames';
// TODO: This may fail if package is split from PWA build // TODO: This may fail if package is split from PWA build
import { useHistory } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { NavBar, Svg, Icon, IconButton, Dropdown } from '@ohif/ui'; import { NavBar, Svg, Icon, IconButton, Dropdown } from '@ohif/ui';
function Header({ children, menuOptions, isReturnEnabled, isSticky, WhiteLabeling }) { function Header({ children, menuOptions, isReturnEnabled, isSticky, WhiteLabeling }) {
const { t } = useTranslation('Header'); const { t } = useTranslation('Header');
const history = useHistory(); const navigate = useNavigate();
// TODO: this should be passed in as a prop instead and the react-router-dom // TODO: this should be passed in as a prop instead and the react-router-dom
// dependency should be dropped // dependency should be dropped
const onReturnHandler = () => { const onReturnHandler = () => {
if (isReturnEnabled) { if (isReturnEnabled) {
history.push('/'); navigate('/');
} }
}; };

View File

@ -0,0 +1,133 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useReducer,
} from 'react';
import PropTypes from 'prop-types';
const DEFAULT_STATE = {
user: null,
enabled: false,
};
export const UserAuthenticationContext = createContext(DEFAULT_STATE);
export function UserAuthenticationProvider({ children, service }) {
const userAuthenticationReducer = (state, action) => {
switch (action.type) {
case 'SET_USER': {
return {
...state,
...{ user: action.payload.user },
};
}
case 'RESET': {
return {
user: null,
};
}
case 'SET': {
return {
...state,
...action.payload,
};
}
default:
return action.payload;
}
};
const [userAuthenticationState, dispatch] = useReducer(
userAuthenticationReducer,
DEFAULT_STATE
);
const getState = useCallback(() => userAuthenticationState, [
userAuthenticationState,
]);
const setUser = useCallback(
user =>
dispatch({
type: 'SET_USER',
payload: {
user,
},
}),
[dispatch]
);
const getUser = useCallback(() => userAuthenticationState.user, [
userAuthenticationState,
]);
const reset = useCallback(
() =>
dispatch({
type: 'RESET',
payload: {},
}),
[dispatch]
);
const set = useCallback(
payload =>
dispatch({
type: 'SET',
payload,
}),
[dispatch]
);
/**
* Sets the implementation of the UserAuthenticationService that can be used by extensions.
*
* @returns void
*/
useEffect(() => {
if (service) {
service.setServiceImplementation({
getState,
setUser,
getUser,
reset,
set,
});
}
}, [getState, service, setUser, getUser, reset, set]);
const api = {
getState,
setUser,
getUser,
getAuthorizationHeader: service.getAuthorizationHeader,
handleUnauthenticated: service.handleUnauthenticated,
reset,
set,
};
return (
<UserAuthenticationContext.Provider value={[userAuthenticationState, api]}>
{children}
</UserAuthenticationContext.Provider>
);
}
export default UserAuthenticationProvider;
const UserAuthenticationConsumer = UserAuthenticationContext.Consumer;
export { UserAuthenticationConsumer };
UserAuthenticationProvider.propTypes = {
children: PropTypes.any,
service: PropTypes.shape({
setServiceImplementation: PropTypes.func,
}).isRequired,
};
export const useUserAuthentication = () =>
useContext(UserAuthenticationContext);

View File

@ -2,7 +2,7 @@ export {
default as DialogProvider, default as DialogProvider,
useDialog, useDialog,
withDialog, withDialog,
} from './DialogProvider' } from './DialogProvider';
export { default as DragAndDropProvider } from './DragAndDropProvider'; export { default as DragAndDropProvider } from './DragAndDropProvider';
@ -19,17 +19,13 @@ export {
useImageViewer, useImageViewer,
} from './ImageViewerProvider'; } from './ImageViewerProvider';
export { export { CineContext, default as CineProvider, useCine } from './CineProvider';
CineContext,
default as CineProvider,
useCine,
} from './CineProvider';
export { export {
default as SnackbarProvider, default as SnackbarProvider,
useSnackbar, useSnackbar,
withSnackbar, withSnackbar,
} from './SnackbarProvider' } from './SnackbarProvider';
export { export {
default as ViewportDialogProvider, default as ViewportDialogProvider,
@ -41,3 +37,9 @@ export {
ViewportGridProvider, ViewportGridProvider,
useViewportGrid, useViewportGrid,
} from './ViewportGridProvider'; } from './ViewportGridProvider';
export {
UserAuthenticationContext,
UserAuthenticationProvider,
useUserAuthentication,
} from './UserAuthenticationProvider';

View File

@ -112,6 +112,10 @@ module.exports = (env, argv) => {
historyApiFallback: { historyApiFallback: {
disableDotRule: true, disableDotRule: true,
}, },
headers: {
'Cross-Origin-Embedder-Policy': 'require-corp',
'Cross-Origin-Opener-Policy': 'same-origin',
},
}, },
}); });

View File

@ -62,17 +62,19 @@
"dicom-parser": "^1.8.3", "dicom-parser": "^1.8.3",
"dotenv-webpack": "^1.7.0", "dotenv-webpack": "^1.7.0",
"hammerjs": "^2.0.8", "hammerjs": "^2.0.8",
"history": "5.0.0",
"i18next": "^17.0.3", "i18next": "^17.0.3",
"i18next-browser-languagedetector": "^3.0.1", "i18next-browser-languagedetector": "^3.0.1",
"lodash.isequal": "4.5.0", "lodash.isequal": "4.5.0",
"moment": "^2.24.0", "moment": "^2.24.0",
"oidc-client": "1.11.5",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"query-string": "^6.12.1", "query-string": "^6.12.1",
"react-dropzone": "^10.1.7", "react-dropzone": "^10.1.7",
"react-i18next": "^10.11.0", "react-i18next": "^10.11.0",
"react-resize-detector": "^4.2.0", "react-resize-detector": "^4.2.0",
"react-router": "^5.2.0", "react-router": "next",
"react-router-dom": "^5.2.0" "react-router-dom": "next"
}, },
"devDependencies": { "devDependencies": {
"@percy/cypress": "^2.3.0", "@percy/cypress": "^2.3.0",

View File

@ -3,7 +3,9 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import i18n from '@ohif/i18n'; import i18n from '@ohif/i18n';
import { I18nextProvider } from 'react-i18next'; import { I18nextProvider } from 'react-i18next';
import { Router } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import Compose from './routes/Mode/Compose.js';
import { import {
DialogProvider, DialogProvider,
Modal, Modal,
@ -13,22 +15,18 @@ import {
ViewportDialogProvider, ViewportDialogProvider,
ViewportGridProvider, ViewportGridProvider,
CineProvider, CineProvider,
UserAuthenticationProvider,
} from '@ohif/ui'; } from '@ohif/ui';
// Viewer Project // Viewer Project
// TODO: Should this influence study list? // TODO: Should this influence study list?
import { AppConfigProvider } from '@state'; import { AppConfigProvider } from '@state';
import createRoutes from './routes'; import createRoutes from './routes';
import appInit from './appInit.js'; import appInit from './appInit.js';
import history from './history' import OpenIdConnectRoutes from './utils/OpenIdConnectRoutes.jsx';
// TODO: Temporarily for testing // TODO: Temporarily for testing
import '@ohif/mode-longitudinal'; import '@ohif/mode-longitudinal';
/**
* ENV Variable to determine routing behavior
*/
const OHIFRouter = Router
let commandsManager, extensionManager, servicesManager, hotkeysManager; let commandsManager, extensionManager, servicesManager, hotkeysManager;
function App({ config, defaultExtensions }) { function App({ config, defaultExtensions }) {
@ -42,7 +40,8 @@ function App({ config, defaultExtensions }) {
// Set appConfig // Set appConfig
const appConfigState = init.appConfig; const appConfigState = init.appConfig;
const { routerBasename, modes, dataSources } = appConfigState; const { routerBasename, modes, dataSources, oidc } = appConfigState;
// Use config to create routes // Use config to create routes
const appRoutes = createRoutes({ const appRoutes = createRoutes({
modes, modes,
@ -50,38 +49,52 @@ function App({ config, defaultExtensions }) {
extensionManager, extensionManager,
servicesManager, servicesManager,
hotkeysManager, hotkeysManager,
routerBasename,
}); });
const { const {
UIDialogService, UIDialogService,
UIModalService, UIModalService,
UINotificationService, UINotificationService,
UIViewportDialogService, UIViewportDialogService,
ViewportGridService, // TODO: Should this be a "UI" Service? ViewportGridService,
CineService CineService,
UserAuthenticationService,
} = servicesManager.services; } = servicesManager.services;
const providers = [
[AppConfigProvider, { value: appConfigState }],
[UserAuthenticationProvider, { service: UserAuthenticationService}],
[I18nextProvider, { i18n }],
[ThemeWrapper],
[ViewportGridProvider, {service: ViewportGridService}],
[ViewportDialogProvider, {service: UIViewportDialogService}],
[CineProvider, {service: CineService}],
[SnackbarProvider, {service: UINotificationService}],
[DialogProvider, {service: UIDialogService}],
[ModalProvider, {service: UIModalService, modal: Modal}],
]
const CombinedProviders = ({ children }) =>
Compose({ components: providers, children });
let authRoutes = null;
if (oidc) {
UserAuthenticationService.set({ enabled: true });
authRoutes = (<OpenIdConnectRoutes
oidc={oidc}
routerBasename={routerBasename}
UserAuthenticationService={UserAuthenticationService}
/>)
}
return ( return (
<AppConfigProvider value={appConfigState}> <CombinedProviders>
<I18nextProvider i18n={i18n}> <BrowserRouter>
<OHIFRouter basename={routerBasename} history={history}> {authRoutes}
<ThemeWrapper> {appRoutes}
<ViewportGridProvider service={ViewportGridService}> </BrowserRouter>
<ViewportDialogProvider service={UIViewportDialogService}> </CombinedProviders>
<CineProvider service={CineService}>
<SnackbarProvider service={UINotificationService}>
<DialogProvider service={UIDialogService}>
<ModalProvider modal={Modal} service={UIModalService}>
{appRoutes}
</ModalProvider>
</DialogProvider>
</SnackbarProvider>
</CineProvider>
</ViewportDialogProvider>
</ViewportGridProvider>
</ThemeWrapper>
</OHIFRouter>
</I18nextProvider>
</AppConfigProvider>
); );
} }

View File

@ -13,11 +13,10 @@ import {
ViewportGridService, ViewportGridService,
HangingProtocolService, HangingProtocolService,
CineService, CineService,
UserAuthenticationService,
// utils, // utils,
} from '@ohif/core'; } from '@ohif/core';
// TODO -> this feels bad.
/** /**
* @param {object|func} appConfigOrFunc - application configuration, or a function that returns application configuration * @param {object|func} appConfigOrFunc - application configuration, or a function that returns application configuration
* @param {object[]} defaultExtensions - array of extension objects * @param {object[]} defaultExtensions - array of extension objects
@ -60,6 +59,7 @@ function appInit(appConfigOrFunc, defaultExtensions) {
ViewportGridService, ViewportGridService,
HangingProtocolService, HangingProtocolService,
CineService, CineService,
UserAuthenticationService,
]); ]);
/** /**
@ -75,13 +75,16 @@ function appInit(appConfigOrFunc, defaultExtensions) {
// TODO: We no longer init webWorkers at app level // TODO: We no longer init webWorkers at app level
// TODO: We no longer init the user Manager // TODO: We no longer init the user Manager
if (!appConfig.modes) {
throw new Error('No modes are defined! Check your app-config.js');
}
// TODO: Remove this // TODO: Remove this
if (!appConfig.modes.length) { if (!appConfig.modes.length) {
appConfig.modes.push(window.longitudinalMode); appConfig.modes.push(window.longitudinalMode);
// appConfig.modes.push(window.segmentationMode); // appConfig.modes.push(window.segmentationMode);
} }
return { return {
appConfig, appConfig,
commandsManager, commandsManager,

View File

@ -1,3 +1,4 @@
// TODO: replace with HistoryRouter https://github.com/ReactTraining/react-router/pull/7586
import { createBrowserHistory, createHashHistory } from 'history'; import { createBrowserHistory, createHashHistory } from 'history';
const useHashRouting = JSON.parse(process.env.USE_HASH_ROUTER); const useHashRouting = JSON.parse(process.env.USE_HASH_ROUTER);
const router = useHashRouting ? createHashHistory() : createBrowserHistory(); const router = useHashRouting ? createHashHistory() : createBrowserHistory();

View File

@ -0,0 +1,22 @@
import React from 'react';
import PropTypes from 'prop-types';
function CallbackPage({ userManager, onRedirectSuccess }) {
const onRedirectError = error => {
throw new Error(error);
};
userManager
.signinRedirectCallback()
.then(user => onRedirectSuccess(user))
.catch(error => onRedirectError(error));
// todo: add i18n (or return null?)
return <div>Redirecting...</div>;
}
CallbackPage.propTypes = {
userManager: PropTypes.object.isRequired,
};
export default CallbackPage;

View File

@ -3,8 +3,8 @@ import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { MODULE_TYPES } from '@ohif/core'; import { MODULE_TYPES } from '@ohif/core';
// //
import { useAppConfig } from '@state';
import { extensionManager } from '../App.jsx'; import { extensionManager } from '../App.jsx';
import { useParams, useLocation } from 'react-router';
/** /**
* Uses route properties to determine the data source that should be passed * Uses route properties to determine the data source that should be passed
@ -15,8 +15,10 @@ import { extensionManager } from '../App.jsx';
* @param {function} props.children - Layout Template React Component * @param {function} props.children - Layout Template React Component
*/ */
function DataSourceWrapper(props) { function DataSourceWrapper(props) {
const [appConfig] = useAppConfig(); const { children: LayoutTemplate, ...rest } = props;
const { children: LayoutTemplate, history, ...rest } = props; const params = useParams();
const location = useLocation();
// TODO: Fetch by type, name, etc? // TODO: Fetch by type, name, etc?
const dataSourceModules = extensionManager.modules[MODULE_TYPES.DATA_SOURCE]; const dataSourceModules = extensionManager.modules[MODULE_TYPES.DATA_SOURCE];
// TODO: Good usecase for flatmap? // TODO: Good usecase for flatmap?
@ -30,13 +32,10 @@ function DataSourceWrapper(props) {
return acc.concat(mods); return acc.concat(mods);
}, []); }, []);
// Grabbing first for now. This isn't hydrated yet, but we should // Grabbing first for now - should get active?
// hydrate it somewhere based on config... const name = webApiDataSources[0].name;
// ~ default.js // TODO: Why does this return an array?
const firstAppConfigDataSource = appConfig.dataSources[0]; const dataSource = extensionManager.getDataSources(name)[0]
const dataSourceConfig = firstAppConfigDataSource.configuration;
const firstWebApiDataSource = webApiDataSources[0];
const dataSource = firstWebApiDataSource.createDataSource(dataSourceConfig);
// Route props --> studies.mapParams // Route props --> studies.mapParams
// mapParams --> studies.search // mapParams --> studies.search
@ -55,19 +54,18 @@ function DataSourceWrapper(props) {
useEffect(() => { useEffect(() => {
const queryFilterValues = _getQueryFilterValues( const queryFilterValues = _getQueryFilterValues(
history.location.search, location.search,
STUDIES_LIMIT STUDIES_LIMIT
); );
// 204: no content // 204: no content
async function getData() { async function getData() {
setIsLoading(true); setIsLoading(true);
const studies = await dataSource.query.studies.search(queryFilterValues); const studies = await dataSource.query.studies.search(queryFilterValues);
setIsLoading(false); setIsLoading(false);
setData({ setData({
studies, studies: studies || [],
total: studies.length, total: studies.length,
resultsPerPage: queryFilterValues.resultsPerPage, resultsPerPage: queryFilterValues.resultsPerPage,
pageNumber: queryFilterValues.pageNumber, pageNumber: queryFilterValues.pageNumber,
@ -99,14 +97,13 @@ function DataSourceWrapper(props) {
console.warn(ex); console.warn(ex);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [history.location.search]); }, [location, params]);
// queryFilterValues // queryFilterValues
// TODO: Better way to pass DataSource? // TODO: Better way to pass DataSource?
return ( return (
<LayoutTemplate <LayoutTemplate
{...rest} {...rest}
history={history}
data={data.studies} data={data.studies}
dataTotal={data.total} dataTotal={data.total}
dataSource={dataSource} dataSource={dataSource}

View File

@ -10,14 +10,18 @@ export default function Compose(props) {
const { components = [], children } = props; const { components = [], children } = props;
return ( return (
<> <React.Fragment>
{components.reduceRight((acc, Comp) => { {components.reduceRight((acc, curr) => {
return <Comp>{acc}</Comp>; const [Comp, props] = Array.isArray(curr)
? [curr[0], curr[1]]
: [curr, {}];
return <Comp {...props}>{acc}</Comp>;
}, children)} }, children)}
</> </React.Fragment>
); );
} }
// https://juliuskoronci.medium.com/avoid-a-long-list-of-react-providers-c45a269d80c1
Compose.propTypes = { Compose.propTypes = {
components: PropTypes.array, components: PropTypes.array,
children: PropTypes.node.isRequired, children: PropTypes.node.isRequired,

View File

@ -80,7 +80,9 @@ export default function ModeRoute({
const { const {
DisplaySetService, DisplaySetService,
HangingProtocolService, HangingProtocolService,
UserAuthenticationService,
} = servicesManager.services; } = servicesManager.services;
const { extensions, sopClassHandlers, hotkeys, hangingProtocols } = mode; const { extensions, sopClassHandlers, hotkeys, hangingProtocols } = mode;
if (dataSourceName === undefined) { if (dataSourceName === undefined) {
@ -96,6 +98,12 @@ export default function ModeRoute({
// Only handling one route per mode for now // Only handling one route per mode for now
const route = mode.routes[0]; const route = mode.routes[0];
const layoutTemplateRouteData = route.layoutTemplate({ location });
const layoutTemplateModuleEntry = extensionManager.getModuleEntry(
layoutTemplateRouteData.id
);
const LayoutComponent = layoutTemplateModuleEntry.component;
// For each extension, look up their context modules // For each extension, look up their context modules
// TODO: move to extension manager. // TODO: move to extension manager.
let contextModules = []; let contextModules = [];

View File

@ -0,0 +1,15 @@
import React from "react";
import { Route } from "react-router-dom";
import { useUserAuthentication } from "@ohif/ui";
export const PrivateRoute = ({ ...rest }) => {
const [{ user, enabled }, userAuthenticationService] = useUserAuthentication();
if (enabled && !user) {
return userAuthenticationService.handleUnauthenticated();
}
return <Route {...rest}/>;
}
export default PrivateRoute;

View File

@ -0,0 +1,33 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import PropTypes from 'prop-types';
function SignoutCallbackComponent({ userManager }) {
const navigate = useNavigate();
const onRedirectSuccess = (/* user */) => {
const { pathname, search = '' } = JSON.parse(
sessionStorage.getItem('ohif-redirect-to')
);
navigate(`${pathname}?${search}`);
};
const onRedirectError = error => {
throw new Error(error);
};
userManager
.signoutRedirectCallback()
.then(user => onRedirectSuccess(user))
.catch(error => onRedirectError(error));
// todo: add i18n
return <div>Redirecting...</div>;
}
SignoutCallbackComponent.propTypes = {
userManager: PropTypes.object.isRequired,
};
export default SignoutCallbackComponent;

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Link } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import moment from 'moment'; import moment from 'moment';
import qs from 'query-string'; import qs from 'query-string';
import isEqual from 'lodash.isequal'; import isEqual from 'lodash.isequal';
@ -38,7 +38,6 @@ const seriesInStudiesMap = new Map();
* - debounce `setFilterValues` (150ms?) * - debounce `setFilterValues` (150ms?)
*/ */
function WorkList({ function WorkList({
history,
data: studies, data: studies,
dataTotal: studiesTotal, dataTotal: studiesTotal,
isLoadingData, isLoadingData,
@ -52,6 +51,7 @@ function WorkList({
const [appConfig] = useAppConfig(); const [appConfig] = useAppConfig();
// ~ Filters // ~ Filters
const query = useQuery(); const query = useQuery();
const navigate = useNavigate();
const STUDIES_LIMIT = 101; const STUDIES_LIMIT = 101;
const queryFilterValues = _getQueryFilterValues(query); const queryFilterValues = _getQueryFilterValues(query);
const [filterValues, _setFilterValues] = useState({ const [filterValues, _setFilterValues] = useState({
@ -175,7 +175,7 @@ function WorkList({
} }
}); });
history.push({ navigate({
pathname: '/', pathname: '/',
search: `?${qs.stringify(queryString, { search: `?${qs.stringify(queryString, {
skipNull: true, skipNull: true,
@ -441,9 +441,6 @@ function WorkList({
} }
WorkList.propTypes = { WorkList.propTypes = {
history: PropTypes.shape({
push: PropTypes.func,
}).isRequired,
data: PropTypes.array.isRequired, data: PropTypes.array.isRequired,
dataSource: PropTypes.shape({ dataSource: PropTypes.shape({
query: PropTypes.object.isRequired, query: PropTypes.object.isRequired,

View File

@ -26,7 +26,7 @@ export default function buildModeRoutes({
dataSources, dataSources,
extensionManager, extensionManager,
servicesManager, servicesManager,
hotkeysManager hotkeysManager,
}) { }) {
const routes = []; const routes = [];
@ -51,20 +51,19 @@ export default function buildModeRoutes({
// TODO move up. // TODO move up.
const component = ({ location }) => ( const component = ({ location }) => (
<ModeRoute <ModeRoute
location={location} location={location}
mode={mode} mode={mode}
dataSourceName={dataSourceName} dataSourceName={dataSourceName}
extensionManager={extensionManager} extensionManager={extensionManager}
servicesManager={servicesManager} servicesManager={servicesManager}
hotkeysManager={hotkeysManager} hotkeysManager={hotkeysManager}
/> />
); );
routes.push({ routes.push({
path, path,
component, component,
exact: true,
}); });
}); });
@ -74,21 +73,21 @@ export default function buildModeRoutes({
const path = `/${mode.id}`; const path = `/${mode.id}`;
// TODO move up. // TODO move up.
const component = ({ location }) => ( const children = ({ location }) => (
<ModeRoute <ModeRoute
location={location} location={location}
mode={mode} mode={mode}
dataSourceName={defaultDataSourceName} dataSourceName={defaultDataSourceName}
extensionManager={extensionManager} extensionManager={extensionManager}
servicesManager={servicesManager} servicesManager={servicesManager}
hotkeysManager={hotkeysManager} hotkeysManager={hotkeysManager}
/> />
); );
routes.push({ routes.push({
path, path,
component, children,
exact: true, private: true, // todo: all mode routes are private for now
}); });
}); });

View File

@ -1,12 +1,14 @@
import React from 'react'; import React from 'react';
import { Switch, Route } from 'react-router-dom'; import { Routes, Route } from 'react-router-dom';
import { ErrorBoundary } from '@ohif/ui';
// Route Components // Route Components
import DataSourceWrapper from './DataSourceWrapper'; import DataSourceWrapper from './DataSourceWrapper';
import WorkList from './WorkList'; import WorkList from './WorkList';
import Local from './Local'; import Local from './Local';
import NotFound from './NotFound'; import NotFound from './NotFound';
import buildModeRoutes from './buildModeRoutes'; import buildModeRoutes from './buildModeRoutes';
import { ErrorBoundary } from '@ohif/ui'; import PrivateRoute from './PrivateRoute';
// TODO: Make these configurable // TODO: Make these configurable
// TODO: Include "routes" debug route if dev build // TODO: Include "routes" debug route if dev build
@ -14,14 +16,13 @@ const bakedInRoutes = [
// WORK LIST // WORK LIST
{ {
path: '/', path: '/',
exact: true, children: DataSourceWrapper,
component: DataSourceWrapper, private: true,
props: { children: WorkList }, props: { children: WorkList },
}, },
{ {
path: '/local', path: '/local',
exact: true, children: Local,
component: Local,
}, },
// NOT FOUND (404) // NOT FOUND (404)
{ component: NotFound }, { component: NotFound },
@ -33,6 +34,7 @@ const createRoutes = ({
extensionManager, extensionManager,
servicesManager, servicesManager,
hotkeysManager, hotkeysManager,
routerBasename,
}) => { }) => {
const routes = const routes =
buildModeRoutes({ buildModeRoutes({
@ -45,31 +47,44 @@ const createRoutes = ({
const allRoutes = [...routes, ...bakedInRoutes]; const allRoutes = [...routes, ...bakedInRoutes];
function RouteWithErrorBoundary({ route, ...rest }) {
// eslint-disable-next-line react/jsx-props-no-spreading
return (
<ErrorBoundary context={`Route ${route.path}`} fallbackRoute="/">
<route.children
{...rest}
{...route.props}
route={route}
servicesManager={servicesManager}
hotkeysManager={hotkeysManager}
/>
</ErrorBoundary>
);
}
const { UserAuthenticationService } = servicesManager.services;
return ( return (
<Switch> <Routes basename={routerBasename}>
{allRoutes.map((route, i) => { {allRoutes.map((route, i) => {
return ( return route.private === true ? (
<PrivateRoute
key={i}
path={route.path}
handleUnauthenticated={
UserAuthenticationService.handleUnauthenticated
}
element={<RouteWithErrorBoundary route={route} />}
/>
) : (
<Route <Route
key={i} key={i}
path={route.path} path={route.path}
exact={route.exact} element={<RouteWithErrorBoundary route={route} />}
strict={route.strict}
render={props => (
// eslint-disable-next-line react/jsx-props-no-spreading
<ErrorBoundary context={`Route ${route.path}`} fallbackRoute="/">
<route.component
{...props}
{...route.props}
route={route}
servicesManager={servicesManager}
hotkeysManager={hotkeysManager}
/>
</ErrorBoundary>
)}
/> />
); );
})} })}
</Switch> </Routes>
); );
}; };

View File

@ -0,0 +1,180 @@
import React from 'react';
import { Route, Routes, useLocation, useNavigate } from 'react-router';
import CallbackPage from '../routes/CallbackPage';
import SignoutCallbackComponent from '../routes/SignoutCallbackComponent';
import getUserManagerForOpenIdConnectClient from './getUserManagerForOpenIdConnectClient.js';
function _isAbsoluteUrl(url) {
return url.includes('http://') || url.includes('https://');
}
function _makeAbsoluteIfNecessary(url, base_url) {
if (_isAbsoluteUrl(url)) {
return url;
}
/*
* Make sure base_url and url are not duplicating slashes.
*/
if (base_url[base_url.length - 1] === '/') {
base_url = base_url.slice(0, base_url.length - 1);
}
return base_url + url;
}
const initUserManager = (oidc, routerBasename) => {
if (!oidc || !oidc.length) {
return;
}
const firstOpenIdClient = oidc[0];
const { protocol, host } = window.location;
const baseUri = `${protocol}//${host}${routerBasename}`;
const redirect_uri = firstOpenIdClient.redirect_uri || '/callback';
const silent_redirect_uri =
firstOpenIdClient.silent_redirect_uri || '/silent-refresh.html';
const post_logout_redirect_uri =
firstOpenIdClient.post_logout_redirect_uri || '/';
const openIdConnectConfiguration = Object.assign({}, firstOpenIdClient, {
redirect_uri: _makeAbsoluteIfNecessary(redirect_uri, baseUri),
silent_redirect_uri: _makeAbsoluteIfNecessary(
silent_redirect_uri,
baseUri
),
post_logout_redirect_uri: _makeAbsoluteIfNecessary(
post_logout_redirect_uri,
baseUri
),
});
return getUserManagerForOpenIdConnectClient(openIdConnectConfiguration);
}
function LoginComponent(userManager) {
const queryParams = new URLSearchParams(location.search);
const iss = queryParams.get('iss');
const loginHint = queryParams.get('login_hint');
const targetLinkUri = queryParams.get('target_link_uri');
if (iss !== oidcAuthority) {
console.error(
'iss of /login does not match the oidc authority'
);
return null;
}
userManager.removeUser().then(() => {
if (targetLinkUri !== null) {
const ohifRedirectTo = {
pathname: new URL(targetLinkUri).pathname,
};
sessionStorage.setItem(
'ohif-redirect-to',
JSON.stringify(ohifRedirectTo)
);
} else {
const ohifRedirectTo = {
pathname: '/',
};
sessionStorage.setItem(
'ohif-redirect-to',
JSON.stringify(ohifRedirectTo)
);
}
if (loginHint !== null) {
userManager.signinRedirect({ login_hint: loginHint });
} else {
userManager.signinRedirect();
}
});
return null;
}
function OpenIdConnectRoutes({
oidc,
routerBasename,
UserAuthenticationService
}) {
const userManager = initUserManager(oidc, routerBasename);
const getAuthorizationHeader = (user) => {
return {
Authorization: `Bearer ${user.access_token}`
};
}
const handleUnauthenticated = () => {
userManager.signinRedirect()
// return null because this is used in a react component
return null;
};
const navigate = useNavigate();
UserAuthenticationService.setServiceImplementation({
getAuthorizationHeader,
handleUnauthenticated
});
const oidcAuthority = oidc[0].authority;
const location = useLocation();
const { pathname, search } = location;
const redirect_uri = new URL(userManager.settings._redirect_uri).pathname//.replace(routerBasename,'')
const silent_refresh_uri = new URL(userManager.settings._silent_redirect_uri).pathname//.replace(routerBasename,'')
const post_logout_redirect_uri = new URL(userManager.settings._post_logout_redirect_uri).pathname//.replace(routerBasename,'');
// const pathnameRelative = pathname.replace(routerBasename,'');
if (pathname !== redirect_uri) {
sessionStorage.setItem(
'ohif-redirect-to',
JSON.stringify({ pathname, search })
);
}
return (
<Routes basename={routerBasename}>
<Route
path={silent_refresh_uri}
onEnter={window.location.reload}
/>
<Route
path={post_logout_redirect_uri}
element={
<SignoutCallbackComponent
userManager={userManager}
successCallback={() => console.log('Signout successful')}
errorCallback={error => {
console.warn(error);
console.warn('Signout failed');
}}
/>
}
/>
<Route
path={redirect_uri}
element={<CallbackPage userManager={userManager} onRedirectSuccess={(user) => {
const { pathname, search = '' } = JSON.parse(
sessionStorage.getItem('ohif-redirect-to')
);
UserAuthenticationService.setUser(user);
navigate(`${pathname}?${search}`);
}}/>}
/>
<Route
path="/login"
element={<LoginComponent userManager={userManager} oidcAuthority={oidcAuthority}/>}
/>
</Routes>
);
}
export default OpenIdConnectRoutes;

View File

@ -0,0 +1,31 @@
import { UserManager } from 'oidc-client';
/**
* Creates a userManager from oidcSettings
* LINK: https://github.com/IdentityModel/oidc-client-js/wiki#configuration
*
* @param {Object} oidcSettings
* @param {string} oidcSettings.authServerUrl,
* @param {string} oidcSettings.clientId,
* @param {string} oidcSettings.authRedirectUri,
* @param {string} oidcSettings.postLogoutRedirectUri,
* @param {string} oidcSettings.responseType,
* @param {string} oidcSettings.extraQueryParams,
*/
export default function getUserManagerForOpenIdConnectClient(oidcSettings) {
if (!oidcSettings) {
return;
}
const settings = {
...oidcSettings,
automaticSilentRenew: true,
revokeAccessTokenOnSignout: true,
filterProtocolClaims: true,
loadUserInfo: true,
};
const userManager = new UserManager(settings);
return userManager;
}

3802
yarn.lock

File diff suppressed because it is too large Load Diff