diff --git a/package.json b/package.json index d00bc5f..9b29f49 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,20 @@ { "name": "eeg2bids", "description": "EEG/iEEG to BIDS format Wizard", - "version": "1.0.0", + "version": "1.0.1", "dependencies": { "@electron/remote": "^1.1.0", "electron-log": "^4.3.5", "electron-store": "^8.0.0", + "keytar": "^7.7.0", "prop-types": "^15.7.2", "react": "^17.0.2", "react-color": "^2.19.3", + "react-datepicker": "^4.1.1", "react-dom": "^17.0.2", "react-router-dom": "^5.2.0", "react-scripts": "^4.0.2", + "react-switch": "^6.0.0", "socket.io-client": "^4.1.2" }, "devDependencies": { @@ -30,9 +33,7 @@ "eslint-plugin-jsx-a11y": "^6.4.1", "eslint-plugin-react": "^7.24.0", "lint-staged": "^11.0.0", - "react-datepicker": "^4.1.1", - "react-switch": "^6.0.0", - "wait-on": "^5.3.0" + "wait-on": "^6.0.0" }, "scripts": { "rebuild": "rebuild --runtime=electron --target=11.2.1", @@ -68,9 +69,14 @@ "not ie <= 11", "not op_mini all" ], + "contributors": [ + "Alizée Wickenheiser", + "Christine Rogers", + "Laëtitia Fesselier" + ], "author": { - "name": "Alizée Wickenheiser", - "email": "alizee.wickenheiser@mcgill.ca", + "name": "Loris Team", + "email": "loris-dev@bic.mni.mcgill.ca", "url": "https://github.com/aces/eeg2bids" }, "build": { diff --git a/public/electron.js b/public/electron.js index 64ceb21..b9ece88 100644 --- a/public/electron.js +++ b/public/electron.js @@ -131,6 +131,42 @@ app.on('ready', async () => { createSettingsWindow(); } }); + ipcMain.on('setLorisAuthenticationCredentials', + async (event, credentials) => { + const keytar = require('keytar'); + // Delete all old credentials + const services = await keytar.findCredentials('EEG2BIDS'); + for (const service of services) { + await keytar.deletePassword('EEG2BIDS', service.account); + } + // Set new credentials (secure) + await keytar.setPassword( + 'EEG2BIDS', + credentials.lorisUsername, + credentials.lorisPassword, + ); + // Set lorisURL in electron-store (not secure) + const Store = require('electron-store'); + const schema = { + lorisURL: { + type: 'string', + format: 'url', + }, + }; + const store = new Store({schema}); + store.set('lorisURL', credentials.lorisURL); + }); + ipcMain.handle('getLorisAuthenticationCredentials', async (event, arg) => { + const keytar = require('keytar'); + const credentials = await keytar.findCredentials('EEG2BIDS'); + const Store = require('electron-store'); + const store = new Store(); + return { + lorisURL: store.get('lorisURL') ?? '', + lorisUsername: credentials[0] ? credentials[0].account : '', + lorisPassword: credentials[0] ? credentials[0].password : '', + }; + }); }); app.on('window-all-closed', () => { diff --git a/public/index.html b/public/index.html index 46bdbd1..8fbd00b 100644 --- a/public/index.html +++ b/public/index.html @@ -2,7 +2,7 @@ - + diff --git a/public/preload.js b/public/preload.js index 7b717d8..6b2f8fb 100644 --- a/public/preload.js +++ b/public/preload.js @@ -1,5 +1,8 @@ const {contextBridge} = require('electron'); +/** + * contextBridge should be cautious of security risk. + */ contextBridge.exposeInMainWorld('myAPI', { dialog: () => { const {dialog} = require('@electron/remote'); @@ -25,6 +28,18 @@ contextBridge.exposeInMainWorld('myAPI', { const {shell} = require('electron'); shell.openExternal('https://mcin.ca'); }, + getLorisAuthenticationCredentials: async () => { + const ipcRenderer = require('electron').ipcRenderer; + const credentials = await ipcRenderer.invoke( + 'getLorisAuthenticationCredentials', + null, + ); + return credentials; + }, + setLorisAuthenticationCredentials: (credentials) => { + const ipcRenderer = require('electron').ipcRenderer; + ipcRenderer.send('setLorisAuthenticationCredentials', credentials); + }, openSettings: () => { const ipcRenderer = require('electron').ipcRenderer; ipcRenderer.send('openSettingsWindow', null); diff --git a/python/eeg2bids.py b/python/eeg2bids.py index 9852b9f..75ea34f 100644 --- a/python/eeg2bids.py +++ b/python/eeg2bids.py @@ -8,13 +8,24 @@ from python.libs.loris_api import LorisAPI import csv +# EEG2BIDS Wizard version +appVersion = '1.0.1' + +# LORIS credentials of user +lorisCredentials = { + 'lorisURL': '', + 'lorisUsername': '', + 'lorisPassword': '', +} + # Create socket listener. sio = socketio.Server(async_mode='eventlet', cors_allowed_origins=[]) app = socketio.WSGIApp(sio) loris_api = LorisAPI() -# EEG2BIDS Wizard version -appVersion = '1.0.0' + +# Create Loris API handler. +loris_api = LorisAPI() @sio.event @@ -27,8 +38,8 @@ def connect(sid, environ): def tarfile_bids_thread(data): iEEG.TarFile(data) response = { - 'compression_time': 'example_5mins' - } + 'compression_time': 'example_5mins' + } return eventlet.tpool.Proxy(response) @@ -40,29 +51,46 @@ def tarfile_bids(sid, data): print('response received!') print(response) send = { - 'compression_time': response['compression_time'] - } + 'compression_time': response['compression_time'] + } print('send received!') print(send) sio.emit('response', send) @sio.event +def set_loris_credentials(sid, data): + print('set_loris_credentials:', data) + lorisCredentials = data + if lorisCredentials.lorisURL.endswith('/'): + lorisCredentials.lorisURL = lorisCredentials.lorisURL[:-1] + loris_api.url = lorisCredentials.lorisURL + '/api/v0.0.4-dev/' + loris_api.username = lorisCredentials.lorisUsername + loris_api.password = lorisCredentials.lorisPassword + loris_api.login() + sio.emit('loris_sites', loris_api.get_sites()) + sio.emit('loris_projects', loris_api.get_projects()) + + def get_loris_sites(sid): sio.emit('loris_sites', loris_api.get_sites()) + @sio.event def get_loris_projects(sid): sio.emit('loris_projects', loris_api.get_projects()) + @sio.event def get_loris_subprojects(sid, project): sio.emit('loris_subprojects', loris_api.get_subprojects(project)) + @sio.event def get_loris_visits(sid, project): sio.emit('loris_visits', loris_api.get_visits(project)) + @sio.event def ieeg_get_header(sid, data): # data = { file_path: 'path to iEEG file' } @@ -80,6 +108,7 @@ def ieeg_get_header(sid, data): } sio.emit('edf_header', response) + @sio.event def get_metadata(sid, data): # data = { file_path: 'path to metadata file' } @@ -105,6 +134,7 @@ def get_metadata(sid, data): sio.emit('metadata', response) + def edf_to_bids_thread(data): print('data is ') print(data) diff --git a/python/libs/loris_api.py b/python/libs/loris_api.py index 2d646d4..f4b9180 100644 --- a/python/libs/loris_api.py +++ b/python/libs/loris_api.py @@ -2,14 +2,16 @@ import requests import urllib + class LorisAPI: url = 'https://localhost/api/v0.0.4-dev/' username = '' password = '' + token = '' def __init__(self): - self.login() + # self.login() def login(self): resp = json.loads(requests.post( @@ -29,9 +31,9 @@ def login(self): def get_projects(self): resp = requests.get( - url = self.url + 'projects', - headers = {'Authorization': 'Bearer %s' % self.token, 'LORIS-Overwrite': 'overwrite'}, - verify = False + url=self.url + 'projects', + headers={'Authorization': 'Bearer %s' % self.token, 'LORIS-Overwrite': 'overwrite'}, + verify=False ) json_resp = json.loads(resp.content.decode('ascii')) @@ -47,9 +49,9 @@ def get_visits(self, project): def get_sites(self): resp = requests.get( - url = self.url + 'sites', - headers = {'Authorization': 'Bearer %s' % self.token, 'LORIS-Overwrite': 'overwrite'}, - verify = False + url=self.url + 'sites', + headers={'Authorization': 'Bearer %s' % self.token, 'LORIS-Overwrite': 'overwrite'}, + verify=False ) json_resp = json.loads(resp.content.decode('ascii')) @@ -58,9 +60,9 @@ def get_sites(self): def get_project(self, project): resp = requests.get( - url = self.url + 'projects/' + urllib.parse.quote(project), - headers = {'Authorization': 'Bearer %s' % self.token, 'LORIS-Overwrite': 'overwrite'}, - verify = False + url=self.url + 'projects/' + urllib.parse.quote(project), + headers={'Authorization': 'Bearer %s' % self.token, 'LORIS-Overwrite': 'overwrite'}, + verify=False ) json_resp = json.loads(resp.content.decode('ascii')) @@ -68,22 +70,22 @@ def get_project(self, project): def save_instrument(self): resp = requests.put( - url = self.url + '/candidates/661630/V1/instruments/pet_mri_scans', - headers = {'Authorization': 'Bearer %s' % self.token, 'LORIS-Overwrite': 'overwrite'}, - data = json.dumps({ - "Meta" : { - "Instrument" : 'PET/MRI scans', - "Visit" : 'V1', - "Candidate" : 661630, - "DDE" : False + url=self.url + '/candidates/661630/V1/instruments/pet_mri_scans', + headers={'Authorization': 'Bearer %s' % self.token, 'LORIS-Overwrite': 'overwrite'}, + data=json.dumps({ + "Meta": { + "Instrument": 'PET/MRI scans', + "Visit": 'V1', + "Candidate": 661630, + "DDE": False }, - "PET/MRI scans" : { - "Date_taken" : "2021-06-07", - "Examiner" : "Rida", - "completion-date" : "2021-06-07", + "PET/MRI scans": { + "Date_taken": "2021-06-07", + "Examiner": "Rida", + "completion-date": "2021-06-07", } }), - verify = False + verify=False ) print(resp) @@ -92,18 +94,18 @@ def save_instrument(self): def get_visit(self, candid, visit, site, subproject, project): resp = requests.get( - url = self.url + '/candidates/' + str(candid) + '/' + urllib.parse.quote(visit), - headers = {'Authorization': 'Bearer %s' % self.token, 'LORIS-Overwrite': 'overwrite'}, - data = json.dumps({ - "Meta" : { - "CandID" : candid, - "Visit" : visit, - "Site" : site, - "Battery" : subproject, - "Project" : project + url=self.url + '/candidates/' + str(candid) + '/' + urllib.parse.quote(visit), + headers={'Authorization': 'Bearer %s' % self.token, 'LORIS-Overwrite': 'overwrite'}, + data=json.dumps({ + "Meta": { + "CandID": candid, + "Visit": visit, + "Site": site, + "Battery": subproject, + "Project": project } }), - verify = False + verify=False ) json_resp = json.loads(resp.content.decode('ascii')) @@ -111,35 +113,35 @@ def get_visit(self, candid, visit, site, subproject, project): def start_next_stage(self, candid, visit, site, subproject, project, date): resp = requests.patch( - url = self.url + '/candidates/' + str(candid) + '/' + urllib.parse.quote(visit), - headers = {'Authorization': 'Bearer %s' % self.token, 'LORIS-Overwrite': 'overwrite'}, - data = json.dumps({ - "CandID" : candid, - "Visit" : visit, - "Site" : site, - "Battery" : subproject, - "Project" : project, - "NextStageDate" : date + url=self.url + '/candidates/' + str(candid) + '/' + urllib.parse.quote(visit), + headers={'Authorization': 'Bearer %s' % self.token, 'LORIS-Overwrite': 'overwrite'}, + data=json.dumps({ + "CandID": candid, + "Visit": visit, + "Site": site, + "Battery": subproject, + "Project": project, + "NextStageDate": date }), - verify = False + verify=False ) - print (resp.status_code) + print(resp.status_code) print(resp.text) def create_candidate(self): resp = requests.post( - url = self.url + '/candidates/', - headers = {'Authorization': 'Bearer %s' % self.token, 'LORIS-Overwrite': 'overwrite'}, - data = json.dumps({ - "Candidate" : { - "Project" : 'Pumpernickel', - "DoB" : "1985-12-22", - "Sex" : "Female", - "Site" : 'Montreal', + url=self.url + '/candidates/', + headers={'Authorization': 'Bearer %s' % self.token, 'LORIS-Overwrite': 'overwrite'}, + data=json.dumps({ + "Candidate": { + "Project": 'Pumpernickel', + "DoB": "1985-12-22", + "Sex": "Female", + "Site": 'Montreal', } }), - verify = False + verify=False ) print(resp) diff --git a/src/App.js b/src/App.js index 901b91d..fba5a07 100644 --- a/src/App.js +++ b/src/App.js @@ -52,7 +52,7 @@ const App = () => { setTask(task); }, getFromTask: (key) => { - return task[key]; + return task[key] ?? ''; }, }}> <> diff --git a/src/css/Authentication.css b/src/css/Authentication.css new file mode 100644 index 0000000..f191af6 --- /dev/null +++ b/src/css/Authentication.css @@ -0,0 +1,91 @@ +.authMessageContainer { + color: #0b4681; + padding: 10px; + display: block; + cursor: default; + font-size: 12px; + background-color: #e9eaee; +} +.loginMessage { + font-size: 14px; +} +.loginLink { + color: #3b5ead; + font-weight: bolder; + cursor: pointer; +} + +.authCredentialsOverlay { + top: 0; + width: 100%; + height: 100%; + z-index: 102; + overflow: auto; + display: block; + position: fixed; + color: #222428; + pointer-events: auto; + background-color: rgba(33,37,51,0.8); +} +.authCredentialsContainer { + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 104; + position: fixed; + overflow: hidden; + pointer-events: none; +} +.authCredentialsAnimation { + width: auto; + z-index: 105; + display: flex; + overflow: hidden; + margin: .2rem auto; + position: relative; + align-items: center; + pointer-events: none; + transform: translate(0); + min-height: calc(100vh); +} +.authCredentialsDialog { + width: 100%; + z-index: 105; + display: flex; + overflow: scroll; + position: relative; + border-radius: 3px; + color: #222428; + pointer-events: auto; + flex-direction: column; + overflow-y: auto; + overflow-x: hidden; + max-height: 500px; + -webkit-overflow-scrolling: touch; + transition: transform .3s ease-out; + will-change: transform; + background-color: #eaecef; +} +.authCredentialsHeader { + height: 30px; + display: flex; + padding: 30px 30px 0 30px; + font-size: 18pt; + align-items: center; + flex-direction: row; + -webkit-overflow-scrolling: touch; +} +.authCredentialsHeaderClose { + color: #929192; + cursor: pointer; + font-size: 18pt; + margin-left: auto; + -webkit-overflow-scrolling: touch; +} +.authCredentialsContent { + font-size: 14pt; + min-height: 80px; + padding: 30px; + -webkit-overflow-scrolling: touch; +} diff --git a/src/jsx/Configuration.js b/src/jsx/Configuration.js index 6b08db0..a4b3a5f 100644 --- a/src/jsx/Configuration.js +++ b/src/jsx/Configuration.js @@ -2,8 +2,6 @@ import React, {useContext, useState, useEffect} from 'react'; import {AppContext} from '../context'; import PropTypes from 'prop-types'; import '../css/Configuration.css'; -import Switch from 'react-switch'; -import DatePicker from 'react-datepicker'; import 'react-datepicker/dist/react-datepicker.css'; // Components @@ -16,6 +14,12 @@ import { SelectInput, TextareaInput, } from './elements/inputs'; +import { + AuthenticationMessage, + AuthenticationCredentials, +} from './elements/authentication'; +import Switch from 'react-switch'; +import DatePicker from 'react-datepicker'; // Socket.io import {SocketContext} from './socket.io'; @@ -112,6 +116,11 @@ const Configuration = (props) => { hour: '', minute: '', second: '', subtype: '', }); + const [authCredentialsVisible, setAuthCredentialsVisible] = useState(false); + + useEffect(() => { + Object.keys(state).map((key) => appContext.setTask(key, state[key].get)); + }, []); useEffect(() => { Object.keys(state).map((key) => appContext.setTask(key, state[key].get)); @@ -128,13 +137,13 @@ const Configuration = (props) => { if (!isNaN(parseInt(state.edfHeader.get['year']))) { const date = new Date( state.edfHeader.get['year'] < 85 ? - '20' + state.edfHeader.get['year'] : - '19' + state.edfHeader.get['year'], - (state.edfHeader.get['month']-1), - state.edfHeader.get['day'], - state.edfHeader.get['hour'], - state.edfHeader.get['minute'], - state.edfHeader.get['second'], + Number('20' + state.edfHeader.get['year']) : + Number('19' + state.edfHeader.get['year']), + Number(state.edfHeader.get['month']-1), + Number(state.edfHeader.get['day']), + Number(state.edfHeader.get['hour']), + Number(state.edfHeader.get['minute']), + Number(state.edfHeader.get['second']), ); state.recordingDate.set(date); appContext.setTask('recordingDate', date); @@ -146,7 +155,7 @@ const Configuration = (props) => { */ useEffect(() => { if (socketContext) { - socketContext.emit('get_loris_sites'); + // socketContext.emit('get_loris_sites'); socketContext.on('loris_sites', (sites) => { const siteOpts = []; sites.map((site) => { @@ -155,7 +164,7 @@ const Configuration = (props) => { state.siteOptions.set(siteOpts); }); - socketContext.emit('get_loris_projects'); + // socketContext.emit('get_loris_projects'); socketContext.on('loris_projects', (projects) => { const projectOpts = []; Object.keys(projects).map((project) => { @@ -231,7 +240,7 @@ const Configuration = (props) => { state.LORIScompliant.set(value); break; case 'siteID_API': - if (value == 'Manual entry') { + if (value === 'Manual entry') { value = ''; state.siteUseAPI.set(false); } else { @@ -245,7 +254,7 @@ const Configuration = (props) => { name = 'siteID'; break; case 'projectID_API': - if (value == 'Manual entry') { + if (value === 'Manual entry') { state.projectUseAPI.set(false); value = ''; } else { @@ -261,7 +270,7 @@ const Configuration = (props) => { name = 'projectID'; break; case 'subprojectID_API': - if (value == 'Manual entry') { + if (value === 'Manual entry') { state.subprojectUseAPI.set(false); value = ''; } else { @@ -275,7 +284,7 @@ const Configuration = (props) => { name = 'subprojectID'; break; case 'session_API': - if (value == 'Manual entry') { + if (value === 'Manual entry') { state.sessionUseAPI.set(false); value = ''; } else { @@ -376,8 +385,19 @@ const Configuration = (props) => { const createCandidate = () => { }; + /** + * hideAuthCredentials - display AuthCredentials. + * @param {boolean} hidden + */ + const hideAuthCredentials = (hidden) => { + setAuthCredentialsVisible(!hidden); + }; + return props.visible ? ( <> + Data Configuration @@ -850,6 +870,12 @@ const Configuration = (props) => { + ) : null; }; diff --git a/src/jsx/Welcome.js b/src/jsx/Welcome.js index 14f301a..2d82576 100644 --- a/src/jsx/Welcome.js +++ b/src/jsx/Welcome.js @@ -30,7 +30,7 @@ const Welcome = (props) => { myAPI.visitIssues(); }; /** - * openMCIN - Navigate browser to MCIN. + * openMNE - Navigate browser to MNE. */ const openMNE = () => { const myAPI = window['myAPI']; diff --git a/src/jsx/elements/authentication.js b/src/jsx/elements/authentication.js index fce6657..5a5c640 100644 --- a/src/jsx/elements/authentication.js +++ b/src/jsx/elements/authentication.js @@ -1,24 +1,191 @@ -import React from 'react'; +import React, {useState, useEffect, useContext} from 'react'; +import {AppContext} from '../../context'; import PropTypes from 'prop-types'; +import '../../css/Authentication.css'; -export const Authentication = (props) => { +// Socket.io +import {SocketContext} from '../socket.io'; + +// Components +import { + TextInput, +} from './inputs'; + +export const AuthenticationMessage = (props) => { + // React Context + const appContext = useContext(AppContext); + const socketContext = useContext(SocketContext); + + // React state + const [loginMessage, setLoginMessage] = useState( + 'You are not logged in to LORIS Account', + ); + const [loginLink, setLoginLink] = useState( + 'Log in...', + ); + + /** + * Similar to componentDidMount and componentDidUpdate. + */ + useEffect(async () => { + const myAPI = window['myAPI']; + const credentials = await myAPI.getLorisAuthenticationCredentials(); + if (credentials) { + setLoginMessage(`LORIS Account set as ${credentials.lorisUsername}`); + setLoginLink('Sign in to another account..'); + appContext.setTask('lorisURL', credentials.lorisURL); + appContext.setTask('lorisUsername', credentials.lorisUsername); + appContext.setTask('lorisPassword', credentials.lorisPassword); + socketContext.emit('set_loris_credentials', credentials); + } + }, []); + + /** + * User clicked sign in.. + */ const handleClick = () => { - // Send current file to parent component + props.setAuthCredentialsVisible(true); }; return ( - <> - - +
+ + {loginMessage} + + +     {loginLink} + +
); }; -Authentication.propTypes = { +AuthenticationMessage.propTypes = { onUserInput: PropTypes.func, }; +export const AuthenticationCredentials = (props) => { + // React Context + const appContext = useContext(AppContext); + const socketContext = useContext(SocketContext); + + // React state + const [lorisURL, setLorisURL] = useState(''); + const [lorisUsername, setLorisUsername] = useState(''); + const [lorisPassword, setLorisPassword] = useState(''); + + /** + * Similar to componentDidMount and componentDidUpdate. + */ + useEffect(async () => { + const myAPI = window['myAPI']; + const credentials = await myAPI.getLorisAuthenticationCredentials(); + setLorisURL(credentials.lorisURL); + setLorisUsername(credentials.lorisUsername); + setLorisPassword(credentials.lorisPassword); + }, []); + + /** + * Close the Authentication Credentials + * but first update (?new) credentials. + */ + const handleClose = () => { + const myAPI = window['myAPI']; + const credentials = { + lorisURL: lorisURL, + lorisUsername: lorisUsername, + lorisPassword: lorisPassword, + }; + myAPI.setLorisAuthenticationCredentials(credentials); + socketContext.emit('set_loris_credentials', credentials); + props.close(true); + }; + + /** + * onUserInput - input change by user. + * @param {string} name - element name + * @param {object|string|boolean} value - element value + */ + const onUserInput = (name, value) => { + switch (name) { + case 'lorisURL': + setLorisURL(value); + break; + case 'lorisUsername': + setLorisUsername(value); + break; + case 'lorisPassword': + setLorisPassword(value); + break; + } + // Update the 'task' of app context. + appContext.setTask(name, value); + }; + + // Styling for rendering + const styleVisible = {visibility: props.show ? 'visible' : 'hidden'}; + const styleAnimation = {width: props.width ? props.width : 'auto'}; + const styleContainer = { + opacity: props.show ? 1 : 0, + transform: props.show ? 'translateY(0)' : 'translateY(-25%)', + }; + return ( +
+
+
+
+ + {props.title} + + × + + +
+ + + +
+
+
+
+
+ ); +}; +AuthenticationCredentials.propTypes = { + show: PropTypes.bool.isRequired, + close: PropTypes.func.isRequired, + width: PropTypes.string, + title: PropTypes.string, +}; +AuthenticationCredentials.defaultProps = { + width: null, + title: null, +}; + export default { - Authentication, + AuthenticationMessage, + AuthenticationCredentials, };