diff --git a/package-lock.json b/package-lock.json index 776671f1e2bc2e95fda37972df00391bf7978d69..77b548c5698e2002ff56d6586cbe055ece79822a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4429,9 +4429,9 @@ } }, "caniuse-lite": { - "version": "1.0.30001237", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001237.tgz", - "integrity": "sha512-pDHgRndit6p1NR2GhzMbQ6CkRrp4VKuSsqbcLeOQppYPKOYkKT/6ZvZDvKJUqcmtyWIAHuZq3SVS2vc1egCZzw==" + "version": "1.0.30001242", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001242.tgz", + "integrity": "sha512-KvNuZ/duufelMB3w2xtf9gEWCSxJwUgoxOx5b6ScLXC4kPc9xsczUVCPrQU26j5kOsHM4pSUL54tAZt5THQKug==" }, "capture-exit": { "version": "2.0.0", diff --git a/package.json b/package.json index 77a28fd37298dfac0cbb93cb7dbbefd387214964..1790b6fd137da5084d1bd90ea5c347dceafa6961 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,9 @@ "@material-ui/lab": "4.0.0-alpha.57", "bootstrap": "4.5", "flexlayout-react": "0.5.5", - "jszip": "3.2.0", "jquery": "3.6.0", - "mqtt": "4.2.8", + "jszip": "3.2.0", + "mqtt": "4.3.5", "react": "^17.0.1", "react-bootstrap": "1.4.0", "react-dom": "^17.0.1", @@ -46,6 +46,7 @@ }, "scripts": { "start": "react-scripts start", + "startHTTPS": "HTTPS=true react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject", diff --git a/public/index.html b/public/index.html index dd172158746c1c6a64bd030dc00ee0ab7f45c857..7f0865ce4253fcf744735fc958f2c41579992d56 100644 --- a/public/index.html +++ b/public/index.html @@ -28,6 +28,7 @@ <title>Neurorobotics Platform</title> </head> <body> + <script src="https://iam.ebrains.eu/auth/js/keycloak.js"></script> <noscript>You need to enable JavaScript to run this app.</noscript> <div id="root"></div> <!-- diff --git a/src/App.js b/src/App.js index cf315c63f732a03a45513912529d7e98bd01c93b..8a2e97cb26011a5bfee6e4a5a067cc7beea20e01 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,6 @@ import React from 'react'; -import { HashRouter, Switch, Route } from 'react-router-dom'; + +import { BrowserRouter, Switch, Route } from 'react-router-dom'; import mqtt from 'mqtt'; @@ -34,13 +35,13 @@ class App extends React.Component { <div> <ErrorDialog /> <NotificationDialog/> - <HashRouter> + <BrowserRouter> <Switch> <Route path='/experiments-overview' component={ExperimentOverview} /> <Route path='/simulation-view/:serverIP/:simulationID' component={SimulationView} /> <Route path='/' component={EntryPage} /> </Switch> - </HashRouter> + </BrowserRouter> </div> ); } diff --git a/src/mocks/mock_dialog.json b/src/mocks/mock_dialog.json new file mode 100644 index 0000000000000000000000000000000000000000..da3492dddfba4d9f6336ac42b9ed24ad2850572f --- /dev/null +++ b/src/mocks/mock_dialog.json @@ -0,0 +1,4 @@ +{ + "type" :"Network Error", + "message" : "The experiment is loading" +} \ No newline at end of file diff --git a/src/mocks/mock_scan_storage_response.json b/src/mocks/mock_scan_storage_response.json new file mode 100644 index 0000000000000000000000000000000000000000..afd252ca68505ea50bfbfee034c3c318579ef324 --- /dev/null +++ b/src/mocks/mock_scan_storage_response.json @@ -0,0 +1,4 @@ +{ + "deletedFolders":[0,1], + "addedFolders": [2] +} \ No newline at end of file diff --git a/src/mocks/mock_zip_responses.json b/src/mocks/mock_zip_responses.json new file mode 100644 index 0000000000000000000000000000000000000000..a255a0985dcef21706bbc399693f53d7006be465 --- /dev/null +++ b/src/mocks/mock_zip_responses.json @@ -0,0 +1,11 @@ +[ + { + "zipBaseFolderName": "0", + "destFolderName": "1" + + }, + { + "zipBaseFolderName": "0", + "destFolderName": "2" + } +] \ No newline at end of file diff --git a/src/services/__tests__/dialog-service.test.js b/src/services/__tests__/dialog-service.test.js new file mode 100644 index 0000000000000000000000000000000000000000..5f384e40fc433245f4c830d07aff878a56bdd150 --- /dev/null +++ b/src/services/__tests__/dialog-service.test.js @@ -0,0 +1,125 @@ +/** + * @jest-environment jsdom +*/ +import '@testing-library/jest-dom'; +import 'jest-fetch-mock'; + +import DialogService from '../dialog-service'; + +import MockDialog from '../../mocks/mock_dialog.json'; + +test('makes sure that invoking the constructor fails with the right message', () => { + expect(() => { + new DialogService(); + }).toThrow(Error); + expect(() => { + new DialogService(); + }).toThrowError(Error('Use DialogService.instance')); +}); + +test('the experiments service instance always refers to the same object', () => { + const instance1 = DialogService.instance; + const instance2 = DialogService.instance; + expect(instance1).toBe(instance2); +}); + +test('should emit an event on network error', () => { + jest.spyOn(DialogService.instance, 'networkError').mockImplementation(() => { + return Promise.resolve(); + }); + let NetworkError = MockDialog; + + let confirmNetworkError = (startingNetwork) => { + expect(startingNetwork).toEqual(NetworkError); + }; + DialogService.instance.addListener( + DialogService.EVENTS.Error, + confirmNetworkError + ); + DialogService.instance.networkError(NetworkError); + DialogService.instance.removeListener( + DialogService.EVENTS.Error, + confirmNetworkError + ); +}); + +test('should emit an event on data error', () => { + jest.spyOn(DialogService.instance, 'dataError').mockImplementation(() => { + return Promise.resolve(); + }); + let DataError = MockDialog; + + let confirmDataError = (startingData) => { + expect(startingData).toEqual(DataError); + }; + DialogService.instance.addListener( + DialogService.EVENTS.ERROR, + confirmDataError + ); + DialogService.instance.dataError(DataError); + DialogService.instance.removeListener( + DialogService.EVENTS.ERROR, + confirmDataError + ); +}); + +test('should emit an event on simulation error', () => { + jest.spyOn(DialogService.instance, 'simulationError').mockImplementation(() => { + return Promise.resolve(); + }); + let SimulationError = MockDialog; + + let confirmSimulationError = (startingSimulation) => { + expect(startingSimulation).toEqual(SimulationError); + }; + DialogService.instance.addListener( + DialogService.EVENTS.ERROR, + confirmSimulationError + ); + DialogService.instance.dataError(SimulationError); + DialogService.instance.removeListener( + DialogService.EVENTS.ERROR, + confirmSimulationError + ); +}); + +test('should emit an event on progress notification', () => { + jest.spyOn(DialogService.instance, 'progressNotification').mockImplementation(() => { + return Promise.resolve(); + }); + let ProgressNotification = MockDialog; + + let confirmProgressNotification = (startingProgress) => { + expect(startingProgress).toEqual(ProgressNotification); + }; + DialogService.instance.addListener( + DialogService.EVENTS.ERROR, + confirmProgressNotification + ); + DialogService.instance.dataError(ProgressNotification); + DialogService.instance.removeListener( + DialogService.EVENTS.ERROR, + confirmProgressNotification + ); +}); + +test('should emit an event on warning notification', () => { + jest.spyOn(DialogService.instance, 'warningNotification').mockImplementation(() => { + return Promise.resolve(); + }); + let WarningNotification = MockDialog; + + let confirmWarningNotification = (startingWarning) => { + expect(startingWarning).toEqual(WarningNotification); + }; + + DialogService.instance.addListener( + DialogService.EVENTS.ERROR, + confirmWarningNotification + ); + DialogService.instance.dataError(WarningNotification); + DialogService.instance.removeListener( + DialogService.EVENTS.ERROR, + confirmWarningNotification + ); +}); diff --git a/src/services/authentication-service.js b/src/services/authentication-service.js index 53eaf6864fa63b584c1159094f85265b92fca023..11e40694d38385f3a4a47f10a68226b51527fe20 100644 --- a/src/services/authentication-service.js +++ b/src/services/authentication-service.js @@ -1,5 +1,12 @@ import config from '../config.json'; +/* global Keycloak */ + +let keycloakClient = undefined; + +const INIT_CHECK_INTERVAL_MS = 100; +const INIT_CHECK_MAX_RETRIES = 10; + let _instance = null; const SINGLETON_ENFORCER = Symbol(); @@ -12,11 +19,13 @@ class AuthenticationService { throw new Error('Use ' + this.constructor.name + '.instance'); } - this.CLIENT_ID = config.auth.clientId; - this.STORAGE_KEY = `tokens-${this.CLIENT_ID}@https://services.humanbrainproject.eu/oidc`; - this.PROXY_URL = config.api.proxy.url; + this.proxyURL = config.api.proxy.url; + this.oidcEnabled = config.auth.enableOIDC; + this.clientId = config.auth.clientId; + this.authURL = config.auth.url; + this.STORAGE_KEY = `tokens-${this.clientId}@https://iam.ebrains.eu/auth/realms/hbp`; - this.checkForNewTokenToStore(); + this.init(); } static get instance() { @@ -27,44 +36,122 @@ class AuthenticationService { return _instance; } - /** - * Checks if the current page URL contains access tokens. - * This happens when the successfully logging in at the proxy login page and - * being redirected back with the token info. - * Will automatically remove additional access info and present a clean URL after being redirected. - */ - checkForNewTokenToStore() { - const path = window.location.href; - const accessTokenMatch = /&access_token=([^&]*)/.exec(path); + init() { + this.initialized = false; + if (this.oidcEnabled) { + this.authCollab().then(() => { + this.initialized = true; + }); + } + else { + this.checkForNewLocalTokenToStore(); + this.initialized = true; + } + + this.promiseInitialized = new Promise((resolve, reject) => { + let numChecks = 0; + let checkInterval = setInterval(() => { + numChecks++; + if (numChecks > INIT_CHECK_MAX_RETRIES) { + clearInterval(checkInterval); + reject(); + } + if (this.initialized) { + clearInterval(checkInterval); + resolve(); + } + }, INIT_CHECK_INTERVAL_MS); + }); + } + + authenticate(config) { + if (this.oidcEnabled) { + this.authCollab(config); + } + else { + this.authLocal(config); + } + } + + getToken() { + if (this.oidcEnabled) { + if (keycloakClient && keycloakClient.authenticated) { + keycloakClient + .updateToken(30) + .then(function() {}) + .catch(function() { + console.error('Failed to refresh token'); + }); + return keycloakClient.token; + } + else { + console.error('getToken() - Client is not authenticated'); + } + } + else { + return this.getStoredLocalToken(); + } + } + + logout() { + if (this.oidcEnabled) { + if (keycloakClient && keycloakClient.authenticated) { + keycloakClient.logout(); + keycloakClient.clearStoredLocalToken(); + } + else { + console.error('Client is not authenticated'); + } + } + else { + return this.clearStoredLocalToken(); + } + } + + authLocal(config) { + if (this.authenticating) { + return; + } + this.authenticating = true; + + this.authURL = this.authURL || config.url; + this.clientId = this.clientId || config.clientId; + + let absoluteUrl = /^https?:\/\//i; + if (!absoluteUrl.test(this.authURL)) { + this.authURL = `${this.proxyURL}${this.authURL}`; + } + + this.clearStoredLocalToken(); + window.location.href = `${this.authURL}&client_id=${this + .clientId}&redirect_uri=${encodeURIComponent(window.location.href)}`; + } + checkForNewLocalTokenToStore() { + const path = window.location.pathname; + + const accessTokenMatch = /&access_token=([^&]*)/.exec(path); if (!accessTokenMatch || !accessTokenMatch[1]) { return; } let accessToken = accessTokenMatch[1]; - localStorage.setItem( this.STORAGE_KEY, //eslint-disable-next-line camelcase JSON.stringify([{ access_token: accessToken }]) ); - const pathMinusAccessToken = path.substr(0, path.indexOf('&access_token=')); - window.location.href = pathMinusAccessToken; + + // navigate to clean url + let cleanedPath = path.substr(0, path.indexOf('&')); + window.location = cleanedPath; } - /** - * Clear currently stored access token. - */ - clearStoredToken() { + clearStoredLocalToken() { localStorage.removeItem(this.STORAGE_KEY); } - /** - * Get the stored access token. - * - * @return token The stored access token. Or strings identifying 'no-token' / 'malformed-token'. - */ - getStoredToken() { + getStoredLocalToken() { let storedItem = localStorage.getItem(this.STORAGE_KEY); if (!storedItem) { // this token will be rejected by the server and the client will get a proper auth error @@ -77,31 +164,63 @@ class AuthenticationService { } catch (e) { // this token will be rejected by the server and the client will get a proper auth error - return AuthenticationService.CONSTANTS.MALFORMED_TOKEN; + return 'malformed-token'; } } - /** - * Opens the proxy's authentication page. - * - * @param {*} url The URL of the authentication page. - * If not an absolute URL it is assumed to be a subpage of the proxy. - */ - openAuthenticationPage(url) { - this.clearStoredToken(); - - let absoluteUrl = /^https?:\/\//i; - if (!absoluteUrl.test(url)) { - url = `${this.PROXY_URL}${url}`; + authCollab(config) { + if (this.authenticating) { + return; } - window.location.href = `${url}&client_id=${ - this.CLIENT_ID - }&redirect_uri=${encodeURIComponent(window.location.href)}`; + this.authenticating = true; + + return new Promise(resolve => { + this.authURL = this.authURL || config.url; + + this.initKeycloakClient().then(() => { + if (!keycloakClient.authenticated) { + // User is not authenticated, run login + keycloakClient + .login({ scope: 'openid profile email group' }) + .then(() => { + resolve(true); + }); + } + else { + keycloakClient.loadUserInfo().then(userInfo => { + this.userInfo = userInfo; + resolve(true); + }); + } + }); + }); + } + + initKeycloakClient() { + return new Promise(resolve => { + keycloakClient = Keycloak({ + realm: 'hbp', + clientId: this.clientId, + //'public-client': true, + 'confidential-port': 0, + url: this.authURL, + redirectUri: window.location.href // 'http://localhost:9001/#/esv-private' // + }); + + keycloakClient + .init({ + flow: 'hybrid' /*, responseMode: 'fragment'*/ + }) + .then(() => { + resolve(keycloakClient); + }); + }); } } AuthenticationService.CONSTANTS = Object.freeze({ - MALFORMED_TOKEN: 'malformed-token' + MALFORMED_TOKEN: 'malformed-token', + NO_TOKEN: 'no-token' }); export default AuthenticationService; diff --git a/src/services/experiments/files/__tests__/import-experiment-service.test.js b/src/services/experiments/files/__tests__/import-experiment-service.test.js new file mode 100644 index 0000000000000000000000000000000000000000..e48cefb9b8a5810a223767cb932222f0d0c69757 --- /dev/null +++ b/src/services/experiments/files/__tests__/import-experiment-service.test.js @@ -0,0 +1,31 @@ +/** + * @jest-environment jsdom +*/ +import '@testing-library/jest-dom'; +import 'jest-fetch-mock'; + +import ImportExperimentService from '../import-experiment-service'; + +import MockScanStorageResponse from '../../../../mocks/mock_scan_storage_response.json'; +import MockZipResponses from '../../../../mocks/mock_zip_responses.json'; + +test('makes sure that invoking the constructor fails with the right message', () => { + expect(() => { + new ImportExperimentService(); + }).toThrow(Error); + expect(() => { + new ImportExperimentService(); + }).toThrowError(Error('Use ImportExperimentService.instance')); +}); + +test('makes sure zip responses are encapsulated in an object', async () => { + let importZipResponses = {zipBaseFolderName:['0', '0'], destFolderName:['1', '2'], numberOfZips: 2}; + expect(await ImportExperimentService.instance.getImportZipResponses( + MockZipResponses.map((response) => new Response(JSON.stringify(response))))).toStrictEqual(importZipResponses); +}); + +test('makes sure storage response is prepared', async () => { + let scanStorageResponse = {deletedFoldersNumber:2, addedFoldersNumber:1, deletedFolders:'0, 1', addedFolders:'2' }; + expect(await ImportExperimentService.instance.getScanStorageResponse( + new Response(JSON.stringify(MockScanStorageResponse)))).toStrictEqual(scanStorageResponse); +}); \ No newline at end of file diff --git a/src/services/experiments/files/experiment-storage-service.js b/src/services/experiments/files/experiment-storage-service.js index 8d6326388acea05cc17ed61d00daeb35c439b230..ac20e1a442fb67f751a645406d7390bc3425e2f0 100644 --- a/src/services/experiments/files/experiment-storage-service.js +++ b/src/services/experiments/files/experiment-storage-service.js @@ -253,13 +253,11 @@ class ExperimentStorageService extends HttpService { async setFile(directoryPath, filename, data, byname = true, contentType = 'text/plain') { let directory = directoryPath.replaceAll('/', '%2F'); const url = new URL(`${config.api.proxy.url}${endpoints.proxy.storage.url}/${directory}/${filename}`); - //console.info(url); url.searchParams.append('byname', byname); let requestOptions = { ...this.POSTOptions, ...{ headers: { 'Content-Type': contentType } } }; - //console.info(requestOptions); if (contentType === 'text/plain') { return this.httpRequestPOST(url, data, requestOptions); diff --git a/src/services/experiments/files/import-experiment-service.js b/src/services/experiments/files/import-experiment-service.js index 5ae1b74db8e043de07d94bd2cad534237542ca8a..d7307b9f5a73017969c626e045f03d30b0de4638 100644 --- a/src/services/experiments/files/import-experiment-service.js +++ b/src/services/experiments/files/import-experiment-service.js @@ -7,8 +7,19 @@ import DialogService from '../../dialog-service.js'; const importExperimentURL = `${config.api.proxy.url}${endpoints.proxy.storage.importExperiment.url}`; const scanStorageURL = `${config.api.proxy.url}${endpoints.proxy.storage.scanStorage.url}`; +/** + * The Import Experiment Service performs the requests (Extract), + * processes data such as zip or folder (Transform), + * and passes them to the Import Experiment Component (Load). + * Errors are handled by communicating with the Error Handler Service. + */ + let _instance = null; const SINGLETON_ENFORCER = Symbol(); + +/** + * Non-default options (content type) for the POST request + */ const options = { mode: 'cors', // no-cors, *cors, same-origin cache: 'no-cache', // *default, no-cache, reload, force-cache, only-if-cached @@ -42,13 +53,13 @@ export default class ImportExperimentService extends HttpService { return _instance; } - getImportZipResponses(responses) { + async getImportZipResponses(responses) { let importZipResponses = { zipBaseFolderName: [], destFolderName: [] }; importZipResponses.numberOfZips = responses.length; - responses.forEach(async response =>{ + await responses.forEach(async response =>{ response = await response.json(); importZipResponses['zipBaseFolderName'].push(response['zipBaseFolderName']); importZipResponses['destFolderName'].push(response['destFolderName']); @@ -129,7 +140,7 @@ export default class ImportExperimentService extends HttpService { }); } - readZippedExperimentExperiment(event) { + readZippedExperiment(event) { let files = event.target.files; let zipFiles = []; Array.from(files).forEach(file => { @@ -146,7 +157,7 @@ export default class ImportExperimentService extends HttpService { } importZippedExperiment(event) { - let promises = this.readZippedExperimentExperiment(event) + let promises = this.readZippedExperiment(event) .then(zipContents => zipContents.map(zipContent => this.httpRequestPOST(importExperimentURL, zipContent, options) diff --git a/src/services/http-service.js b/src/services/http-service.js index 28c898193548fb623208b338982bc385f825650c..56ae7299cdd552ebf80e5fb2dbbf002cdf7efdf2 100644 --- a/src/services/http-service.js +++ b/src/services/http-service.js @@ -1,7 +1,7 @@ import { EventEmitter } from 'events'; -import AuthenticationService from './authentication-service.js'; +import AuthenticationService from './authentication-service'; /** * Base class that performs http requests with default request options. @@ -44,7 +44,9 @@ export class HttpService extends EventEmitter { */ performRequest = async (url, options, data) => { // Add authorization header - options.headers.Authorization = `Bearer ${AuthenticationService.instance.getStoredToken()}`; + await AuthenticationService.instance.promiseInitialized; + let token = AuthenticationService.instance.getToken(); + options.headers.Authorization = 'Bearer ' + token; if (data) { options.body = data; } @@ -54,8 +56,7 @@ export class HttpService extends EventEmitter { // error handling if (!response.ok) { if (response.status === 477) { - const responseText = await response.text(); - AuthenticationService.instance.openAuthenticationPage(responseText); + AuthenticationService.instance.authenticate(); } else if (response.status === 478) { //TODO: redirect to maintenance page diff --git a/src/services/proxy/nrp-user-service.js b/src/services/proxy/nrp-user-service.js index 9801ef00e73d8fb9f06f8a02e86b0c82d1abb9b6..08b6af6c3a631eea8eb8aea64c04705c05230553 100644 --- a/src/services/proxy/nrp-user-service.js +++ b/src/services/proxy/nrp-user-service.js @@ -61,7 +61,10 @@ class NrpUserService extends HttpService { */ async getCurrentUser() { if (!this.currentUser) { - this.currentUser = await (await this.httpRequestGET(IDENTITY_ME_URL)).json(); + let responseIdentity = await this.httpRequestGET(IDENTITY_ME_URL); + if (responseIdentity.ok) { + this.currentUser = await responseIdentity.json(); + } } return this.currentUser; diff --git a/src/services/roslib-service.js b/src/services/roslib-service.js index ad6c36403074c73ea426d46077f1c4a87a7679c2..fb47207692ae0a73d3fb7cd02a291a6e988ebdac 100644 --- a/src/services/roslib-service.js +++ b/src/services/roslib-service.js @@ -1,7 +1,7 @@ import * as ROSLIB from 'roslib'; import _ from 'lodash'; -import AuthenticationService from './authentication-service.js'; +import AuthenticationService from './authentication-service'; let _instance = null; const SINGLETON_ENFORCER = Symbol(); @@ -32,7 +32,7 @@ class RoslibService { */ getConnection(url) { if (!this.connections.has(url)) { - let urlWithAuth = url + '?token=' + AuthenticationService.instance.getStoredToken(); + let urlWithAuth = url + '?token=' + AuthenticationService.instance.getToken(); this.connections.set(url, new ROSLIB.Ros({ url: urlWithAuth })); }