diff --git a/.env b/.env index b7107e30..76f60c85 100644 --- a/.env +++ b/.env @@ -1,36 +1,31 @@ -# Development URLS - -REACT_APP_REALM = ClientFhirServer +HTTPS = false +HTTPS_CERT_PATH = server.cert +HTTPS_KEY_PATH = server.key +REACT_APP_ALT_DRUG = true +REACT_APP_AUTH = http://localhost:8180 +REACT_APP_CDS_SERVICE = http://localhost:8090/cds-services REACT_APP_CLIENT = app-login REACT_APP_CLIENT_SCOPES = launch offline_access openid profile user/Patient.read patient/Patient.read user/Practitioner.read -REACT_APP_AUTH = http://localhost:8180 -REACT_APP_SERVER = http://localhost:8090 +REACT_APP_DEFAULT_USER = pra1234 +REACT_APP_EHR_BASE = http://localhost:8080/test-ehr/r4 +REACT_APP_EHR_LINK = http://localhost:8080/ehr-server/ REACT_APP_EHR_SERVER = http://localhost:8080/test-ehr/r4 REACT_APP_EHR_SERVER_TO_BE_SENT_TO_REMS_ADMIN_FOR_PREFETCH = http://localhost:8080/test-ehr/r4 -REACT_APP_EHR_BASE = http://localhost:8080/test-ehr/r4 -REACT_APP_CDS_SERVICE = http://localhost:8090/cds-services REACT_APP_GENERATE_JWT = true -REACT_APP_ORDER_SIGN = rems-order-sign +REACT_APP_GH_PAGES=false +REACT_APP_HOMEPAGE = http://localhost:8080 +REACT_APP_LAUNCH_URL = http://localhost:4040/launch REACT_APP_ORDER_SELECT = rems-order-select -REACT_APP_PATIENT_VIEW = rems-patient-view -REACT_APP_PATIENT_FHIR_QUERY = Patient?_sort=identifier&_count=12 -REACT_APP_USER = alice +REACT_APP_ORDER_SIGN = rems-order-sign REACT_APP_PASSWORD = alice -REACT_APP_PUBLIC_KEYS = http://localhost:3001/public_keys -REACT_APP_ALT_DRUG = true -REACT_APP_LAUNCH_URL = http://localhost:4040/launch -REACT_APP_SMART_LAUNCH_URL = http://localhost:4040/ -REACT_APP_DEFAULT_USER = pra1234 -REACT_APP_RESPONSE_EXPIRATION_DAYS = 30 +REACT_APP_PATIENT_FHIR_QUERY = Patient?_sort=identifier&_count=12 +REACT_APP_PATIENT_VIEW = rems-patient-view REACT_APP_PIMS_SERVER = http://localhost:5051/doctorOrders/api/addRx -REACT_APP_HOMEPAGE = http://localhost:8080 +REACT_APP_PUBLIC_KEYS = http://localhost:3000/request-generator/.well-known/jwks.json +REACT_APP_REALM = ClientFhirServer +REACT_APP_RESPONSE_EXPIRATION_DAYS = 30 +REACT_APP_SERVER = http://localhost:8090 +REACT_APP_SMART_LAUNCH_URL = http://localhost:4040/ REACT_APP_URL = http://localhost:3000 REACT_APP_URL_FILTER = http://localhost:3000/* -REACT_APP_EHR_LINK = http://localhost:8080/ehr-server/ -HTTPS = false -HTTPS_KEY_PATH = server.key -HTTPS_CERT_PATH = server.cert -REACT_APP_GH_PAGES=false -# To Override start command: -# REACT_APP_AUTH=http://example.com PORT=6000 npm start -# Note that .env values can only be accessed by react app starting with 'REACT_APP_' +REACT_APP_USER = alice diff --git a/.github/auto_assign.yml b/.github/auto_assign.yml deleted file mode 100644 index b63c47d6..00000000 --- a/.github/auto_assign.yml +++ /dev/null @@ -1,16 +0,0 @@ -# Set to true to add reviewers to pull requests -addReviewers: true - -# Set to true to add assignees to pull requests -addAssignees: false - -# A list of reviewers to be added to pull requests (GitHub user name) -reviewers: - - zacharyrobin - - KeeyanGhoreshi - - smalho01 - - plarocque4 - - kennyEung - -# A number of reviewers added to the pull request, Set to 0 to add all reviewers -numberOfReviewers: 0 diff --git a/How-To-Launch-SMART-on-FHIR-Apps.md b/How-To-Launch-SMART-on-FHIR-Apps.md new file mode 100644 index 00000000..02824ec4 --- /dev/null +++ b/How-To-Launch-SMART-on-FHIR-Apps.md @@ -0,0 +1,45 @@ +# How to launch external SMART on FHIR apps from mcode/request-generator + +## Setup + +### In mcode/rems-admin + +Overwrite `SMART_ENDPOINT` in the `.env`. Overwriting environment variables in a `.env.local` does not work (this is a bug). For example, if you are using a registered app in a Meld Sandbox, your `SMART_ENDPOINT` may look like this: + +```.env +SMART_ENDPOINT = https://smartlauncher.interop.community/sample-app/launch?client_id=sampleapp&platform=meld +``` + +### In mcode/request-generator + +Set these environment variables in your `.env.local` to overwrite the default values in the `.env`. You must be added to the REMS sandbox on Meld to log in and authenticate when running request-generator locally. + +```.env +REACT_APP_CLIENT = ed8b940e-4aaa-4209-b17d-69dfe67543b9 +REACT_APP_EHR_BASE = https://gw.interop.community/REMS/data +REACT_APP_EHR_SERVER_TO_BE_SENT_TO_REMS_ADMIN_FOR_PREFETCH = https://gw.interop.community/REMS/data +REACT_APP_EHR_SERVER = https://gw.interop.community/REMS/data +REACT_APP_SMART_LAUNCH_URL = https://smartlauncher.interop.community/sample-app/launch?client_id=sampleapp&platform=meld +``` + +### Where to grab the environment variable values if using a Meld Sandbox + +1. `REACT_APP_CLIENT`: This is taken from Apps > Request Generator > Settings > Registered App Details > Client Id. Request Generator refers to the registered mcode/request-generator app in your Meld sandbox. +2. `REACT_APP_EHR_BASE`, `REACT_APP_EHR_SERVER_TO_BE_SENT_TO_REMS_ADMIN_FOR_PREFETCH`, `REACT_APP_EHR_SERVER`: These are taken from your Meld sandbox's sidebar, under Settings > Sandbox > Secured FHIR Server URL. +3. `REACT_APP_SMART_LAUNCH_URL`, `SMART_ENDPOINT`: This is taken from Apps > Sample App > Settings > Registered App Details > App Launch URI\*. + +## How to run + +1. Start request-generator normally and go to `http://localhost:3000/`. Click the "Authorize" button. +2. Start rems-admin normally. +3. In request-generator, click the "Select a Patient" button. +4. Select Jon Snow (id: 130803). +5. Click the "Launch SMART on FHIR app" button. This opens the SMART on FHIR app launch page provided as values to the `REACT_APP_SMART_LAUNCH_URL` and `SMART_ENDPOINT` environment variables. +6. Click the "Authorize" button. +7. You should see the expected SMART on FHIR app launch properly. +8. Go back to request-generator and issue an order-sign hook, and click on the "Patient Enrollment Form" button. +9. You should see the expected SMART on FHIR app launch. + +## Running other Registered SMART on FHIR Apps from Meld + +Log in to Meld at https://meld.interop.community/. Go to My Sandboxes > REMS > Apps to try out the other Registered Apps. The example above manually tests (1). You can try the remaining options after (2) just by changing the `REACT_APP_SMART_LAUNCH_URL` and `SMART_ENDPOINT` environment variables. diff --git a/README.md b/README.md index 04e21b68..b7dd2b33 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,105 @@ # Request Generator -This subproject provides a small web application that is capable of generating requests and displaying the CDS Hooks cards that are provided as a response. This project is written in JavaScript and runs in [node.js](https://nodejs.org/en/). + +This project provides a small web application that is capable of generating requests and displaying the CDS Hooks cards that are provided as a response. This project is written in JavaScript and runs in [node.js](https://nodejs.org/en/). ## Running the request generator standalone -1. Install node.js + +1. Install node.js v14 (using [`nvm`](https://github.com/nvm-sh/nvm) is optional, but easier) + +- `nvm install 14` +- `nvm use 14` + 2. Clone the repository - * `git clone https://github.com/mcode/request-generator.git` + +- `git clone https://github.com/mcode/request-generator.git` + 3. Install the dependencies - * `cd request-generator` - * `npm install` + +- `cd request-generator` +- `npm install` + 4. Run the application - * `npm start` -This should open a browser window directed to the value set in `REACT_APP_URL`. The request-generator assumes the CRD server is running on the default value set for `REACT_APP_SERVER`. This can be changed in the properties file [.env](./.env). [The following section](./README.md#how-to-override-defaults) lists the default values for these environment variables. +- `npm start` + +This should open a browser window directed to the value set in `REACT_APP_URL` followed by the string `/request-generator`. The request-generator assumes the REMS Admin is running on the default value set for `REACT_APP_SERVER`. This can be changed in the properties file [.env](./.env). [The following section](./README.md#how-to-override-defaults) lists the default values for these environment variables. -## Versions -This application requires node v14. +## Keys +Embedded in the application are the public and private keys used to generate and verify JSON Web Tokens (JWT) that are used to authenticate/authorize calls to a CDS-Hooks service. The public key is contained in the public/.well-known/jwks.json document. The private key is contained in src/keys/crdPrivateKey.js file. The keys were generated from https://mkjwk.org/. To update these keys you can generate a new key pair from this site, ensure that you request the Show X.509 option is set to yes. Once generated you can replace the public and private keys. You will also need to update the src/utils/auth.js file with the corresponding key information. ### How To Override Defaults -The .env file contains the default URI paths, these can be overwritten from the start command as follows: - `REACT_APP_LAUNCH_URL=http://example.com PORT=6000 npm start` - -Following are a list of modifiable paths: - -| URI Name | Default | -| -----------------------|---------------------------------------------------------------------------| -| REACT_APP_AUTH | `http://localhost:8180` | -| REACT_APP_EHR_SERVER | `http://localhost:8080/test-ehr/r4` | -| REACT_APP_CDS_SERVICE | `http://localhost:8090/cds-services` | -| REACT_APP_PUBLIC_KEYS | `http://localhost:3001/public_keys` | -| REACT_APP_LAUNCH_URL | `http://localhost:4040/launch` | -| REACT_APP_PIMS_SERVER | `http://localhost:5051/doctorOrders/api/addRx` | -| REACT_APP_REALM | `ClientFhirServer` | -| REACT_APP_CLIENT | `app-login` | -| REACT_APP_SERVER | `http://localhost:8090` | -| REACT_APP_EHR_BASE | `http://localhost:8080/test-ehr/r4` | -| REACT_APP_EHR_SERVER_TO_BE_SENT_TO_REMS_ADMIN_FOR_PREFETCH | `http://localhost:8080/test-ehr/r4` | -| REACT_APP_ORDER_SIGN | `rems-order-sign` | -| REACT_APP_ORDER_SELECT | `rems-order-select` | -| REACT_APP_PATIENT_VIEW | `rems-patient-view` | -| REACT_APP_USER | `alice` | -| REACT_APP_PASSWORD | `alice` | -| REACT_APP_ALT_DRUG | `true` | -| REACT_APP_SMART_LAUNCH_URL | `http://localhost:4040/` | -| REACT_APP_DEFAULT_USER | `pra1234` | -| REACT_APP_RESPONSE_EXPIRATION_DAYS | `30` | -| REACT_APP_HOMEPAGE | `http://localhost:8080` | -| REACT_APP_URL | `http://localhost:3000` | -| REACT_APP_URL_FILTER | `http://localhost:3000/*` | -| REACT_APP_EHR_LINK | `http://localhost:8080/ehr-server/` | -| HTTPS | `false` | -| HTTPS_KEY_PATH | `server.key` | -| HTTPS_CERT_PATH | `server.cert` | -| REACT_APP_PATIENT_FHIR_QUERY | `Patient?_sort=identifier&_count=12` | \ No newline at end of file + +The .env file contains the default URI paths, which can be overwritten from the start command as follows: +a) `REACT_APP_LAUNCH_URL=http://example.com PORT=6000 npm start` or b) by specifying the environment variables and desired values in a `.env.local`. + +Following are a list of modifiable paths: + +| URI Name | Default | +| ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| HTTPS | `false` | +| HTTPS_CERT_PATH | `server.cert` | +| HTTPS_KEY_PATH | `server.key` | +| REACT_APP_ALT_DRUG | `true` | +| REACT_APP_AUTH | `http://localhost:8180` | +| REACT_APP_CDS_SERVICE | `http://localhost:8090/cds-services` | +| REACT_APP_CLIENT | `app-login` | +| REACT_APP_CLIENT_SCOPES | `launch offline_access openid profile user/Patient.read patient/Patient.read user/Practitioner.read` | +| REACT_APP_DEFAULT_USER | `pra1234` | +| REACT_APP_EHR_BASE | `http://localhost:8080/test-ehr/r4` | +| REACT_APP_EHR_LINK | `http://localhost:8080/ehr-server/` | +| REACT_APP_EHR_SERVER | `http://localhost:8080/test-ehr/r4` | +| REACT_APP_EHR_SERVER_TO_BE_SENT_TO_REMS_ADMIN_FOR_PREFETCH | `http://localhost:8080/test-ehr/r4` | +| REACT_APP_GENERATE_JWT | `true` | +| REACT_APP_GH_PAGES | `false` | +| REACT_APP_HOMEPAGE | `http://localhost:8080` | +| REACT_APP_LAUNCH_URL | `http://localhost:4040/launch` | +| REACT_APP_ORDER_SELECT | `rems-order-select` | +| REACT_APP_ORDER_SIGN | `rems-order-sign` | +| REACT_APP_PASSWORD | `alice` | +| REACT_APP_PATIENT_FHIR_QUERY | `Patient?_sort=identifier&_count=12` | +| REACT_APP_PATIENT_VIEW | `rems-patient-view` | +| REACT_APP_PIMS_SERVER | `http://localhost:5051/doctorOrders/api/addRx` | +| REACT_APP_PUBLIC_KEYS | `http://localhost:3000/request-generator/.well-known/jwks.json` | +| REACT_APP_REALM | `ClientFhirServer` | +| REACT_APP_RESPONSE_EXPIRATION_DAYS | `30` | +| REACT_APP_SERVER | `http://localhost:8090` | +| REACT_APP_SMART_LAUNCH_URL | `http://localhost:4040/` | +| REACT_APP_URL | `http://localhost:3000` | +| REACT_APP_URL_FILTER | `http://localhost:3000/*` | +| REACT_APP_USER | `alice` | + +## How to launch as a SMART on FHIR app + +### Using a SMART App Launcher + +1. Go to a SMART app launcher, such as `http://moonshot-dev.mitre.org:4001/index.html` (MITRE) or `https://launch.smarthealthit.org/` (open to public). +2. For the App Launch URL, provide `http://localhost:3000/launch`. + +### Using Meld or a real EHR + +1. If you'd like to launch from Meld, [log in to Meld](https://meld.interop.community/) and follow steps 2-3. +2. The log in page will bring you to the My Sandboxes page. Go to your sandbox. +3. You will land on the Registered Apps page. Click on the circular plus button in the top-right corner and register the request-generator app manually with these settings: + + - Client Type: `Public Client` + - App Launch URI: `http://localhost:3000/launch` + - App Redirect URIs: `http://localhost:3000/#/index, http://localhost:4040/register,http://localhost:3000/index, http://localhost:4040/index` + - Scopes: `launch openid user/*.* offline_access profile` + +4. After registering request-generator, hover over it and click Launch. + + + +## How to launch a SMART on FHIR app from request-generator + +See the [following guide](./How-To-Launch-SMART-on-FHIR-Apps.md) for more information. diff --git a/public/.well-known/jwks.json b/public/.well-known/jwks.json new file mode 100644 index 00000000..0465086b --- /dev/null +++ b/public/.well-known/jwks.json @@ -0,0 +1,14 @@ +{ + "keys": [ + { + "kty": "EC", + "d": "boatWqmVCQvm8wapC7XIF33oydjzXUrb6Mwz4XclkXHCSEYtdxj345LMwFJQAvrN", + "use": "sig", + "crv": "P-384", + "kid": "zGe023HzCFfY7NPb04EGvRDP1oYsTOtLNCNjDgr66AI", + "x": "GJ1EKKadP512kbQLAhu3qftADevkhCcaOFFZi376S8dvhjZU9vxNy3wplJv_GiOr", + "y": "-0nhaXoadjGOAOuMp4ekU7ricjF6So2n57k0N-VrJ9hqA-A0PhnShrmGQdBIEKah", + "alg": "ES384" + } + ] +} diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 00000000..4efe7f24 Binary files /dev/null and b/src/.DS_Store differ diff --git a/src/components/App.js b/src/components/App.js index 16aab97b..dd43cadf 100644 --- a/src/components/App.js +++ b/src/components/App.js @@ -1,21 +1,22 @@ +import { ThemeProvider } from '@mui/styles'; import React from 'react'; -import { BrowserRouter, HashRouter, Routes, Route } from 'react-router-dom'; -import RequestBuilder from '../containers/RequestBuilder'; +import { BrowserRouter, HashRouter, Route, Routes } from 'react-router-dom'; +import Gateway from '../containers/Gateway/Gateway'; +import Index from '../containers/Index'; +import Launch from '../containers/Launch'; import PatientPortal from '../containers/PatientPortal'; +import RegisterPage from '../containers/register/RegisterPage'; import theme from '../containers/styles/theme'; -import { ThemeProvider } from '@mui/styles'; -import Launch from '../containers/Launch'; -import Index from '../containers/Index'; - const isGhPages = process.env.REACT_APP_GH_PAGES === 'true'; const Router = isGhPages ? HashRouter : BrowserRouter; -const redirect = isGhPages ? '/#/index' : '/index'; +const redirect = isGhPages ? '/request-generator/#/index' : '/index'; const App = () => { return ( } /> } /> + } /> { } /> - } /> + } /> ); diff --git a/src/components/ConsoleBox/ConsoleBox.js b/src/components/ConsoleBox/ConsoleBox.js deleted file mode 100644 index 056f7226..00000000 --- a/src/components/ConsoleBox/ConsoleBox.js +++ /dev/null @@ -1,69 +0,0 @@ -import { Button } from '@mui/material'; -import React, { Component } from 'react'; - -export default class ConsoleBox extends Component { - constructor(props) { - super(props); - this.state = { - showStatus: 'hideConsole', - headerStatus: 'collapseHeader' - }; - - this.toggleConsole = this.toggleConsole.bind(this); - } - - handleAddition = (e, { value }) => { - this.setState({ - options: [{ text: value, value }, ...this.state.options] - }); - }; - - handleChange = (e, { value }) => { - this.props.updateCB(this.props.elementName, value); - this.setState({ currentValue: value }); - }; - - toggleConsole() { - if (this.state.showStatus === 'showConsole') { - this.setState({ showStatus: 'hideConsole' }); - this.setState({ headerStatus: 'collapseHeader' }); - } else { - this.setState({ showStatus: 'showConsole' }); - this.setState({ headerStatus: 'showHeader' }); - } - } - - render() { - try { - var objDiv = document.getElementById('your_div'); - if (objDiv) { - objDiv.scrollTop = objDiv.scrollHeight; - } - } catch (error) { - console.log('Encountered error', error); - } - let i = 0; - return ( -
- -
- {this.props.logs.map(element => { - i++; - return ( -
- {' '} - {element.content} -
- ); - })} -
-
- ); - } -} diff --git a/src/components/ConsoleBox/consoleBox.css b/src/components/ConsoleBox/consoleBox.css deleted file mode 100644 index 69359024..00000000 --- a/src/components/ConsoleBox/consoleBox.css +++ /dev/null @@ -1,78 +0,0 @@ -.consoleMain{ - border-width:1px 5px 3px 5px; - border-style: solid solid solid solid; - border-color: black; - background-color: #333333; - color:white; - overflow-y:scroll; - overflow-x: wrap; - word-break:break-all; - font-family: 'Courier New', Courier, monospace; - transition: all .2s ease; - /* transition-duration: .2s; */ - transition-delay: .25s; -} -.resize{ - resize: vertical; - transition-duration:0s; - transition-delay:0s; -} -.showConsole{ - height:200px; - - -} -.hideConsole{ - resize:none; - height:0px !important; - border-width: 0px; - transition-property: all; - transition-duration: .2s; - transition-delay: 0s; -} - -.consoleHeader{ - border: 3px solid black; - background-color: black; - color: white; - margin-top: 30px; - font-family: 'Courier New', Courier, monospace; - display:block; - font-size: 18px; - text-align: center; - transition-property: all; - transition-duration: .2s; - transition-delay: 0s; -} - -.showHeader{ - width:100%; -} - -.collapseHeader{ - width: 250px; - color: black; - text-align:center; - border-width: 1px 1px 1px 1px; - background-color: white; - transition-property: all; - transition-duration: .2s; - transition-delay: .25s; -} - -.showHeader:hover ~ .resize{ - transition-delay:.25s; - transition-duration:.4s; -} - -.errorClass{ - color:#dc3545; -} - -.warningClass{ - color: #ffc107; -} - -.infoClass{ - color: #17a2b8; -} \ No newline at end of file diff --git a/src/components/Dashboard/Dashboard.jsx b/src/components/Dashboard/Dashboard.jsx index 6c6acbef..c5304a3a 100644 --- a/src/components/Dashboard/Dashboard.jsx +++ b/src/components/Dashboard/Dashboard.jsx @@ -100,8 +100,8 @@ const Dashboard = props => { - {createIcons().map(option => ( -
+ {createIcons().map((option, index) => ( +
{option[1]} diff --git a/src/components/RequestBox/InProgressFormBox/InProgressFormBox.js b/src/components/RequestBox/InProgressFormBox/InProgressFormBox.js new file mode 100644 index 00000000..a9d90891 --- /dev/null +++ b/src/components/RequestBox/InProgressFormBox/InProgressFormBox.js @@ -0,0 +1,29 @@ +import { Box, Button, Typography, ButtonGroup } from '@mui/material'; +import React from 'react'; +import './InProgressFormBoxStyle.css'; + +export default function InProgressFormBox(props) { + return ( + props.qrResponse?.questionnaire ? ( + + + In Progress Form + + + Practitioner: {props.qrResponse.author ? props.qrResponse.author.reference : 'empty'} + + + Last Edited: {props.qrResponse.authored ? props.qrResponse.authored : 'empty'} + + + Form Link: {props.qrResponse.questionnaire ? props.qrResponse.questionnaire : 'empty'} + + + + + + ) : '' + ); +} \ No newline at end of file diff --git a/src/components/RequestBox/InProgressFormBox/InProgressFormBoxStyle.css b/src/components/RequestBox/InProgressFormBox/InProgressFormBoxStyle.css new file mode 100644 index 00000000..ceea9016 --- /dev/null +++ b/src/components/RequestBox/InProgressFormBox/InProgressFormBoxStyle.css @@ -0,0 +1,7 @@ +.inprogress-container { + background-color:#f8f8f8; + border: 1px solid black; + border-radius: 5px; + padding:20px; + margin:20px 0 20px 0; +} \ No newline at end of file diff --git a/src/components/RequestBox/PatientSearchBar/PatientSearchBar.js b/src/components/RequestBox/PatientSearchBar/PatientSearchBar.js index 123ed142..250428f4 100644 --- a/src/components/RequestBox/PatientSearchBar/PatientSearchBar.js +++ b/src/components/RequestBox/PatientSearchBar/PatientSearchBar.js @@ -24,20 +24,37 @@ export default function PatientSearchBar(props) { } return ''; } + + function getFilteredLength(searchstring, listOfPatients) { + const filteredListOfPatients = listOfPatients[0].filter((element) => { + if (searchstring === '') { + return element; + } + else { + return element.name.toLowerCase().includes(searchstring); + } + }); + + return filteredListOfPatients.length; + } function patientSearchBar() { return ( - { - setInput(newInputValue.toLowerCase()); - }} - options={listOfPatients[0].map(item => item.name)} - renderInput={(params) => } /> + +

Filter patient list

+ { + setInput(newInputValue.toLowerCase()); + }} + options={listOfPatients[0].map(item => item.name)} + renderInput={(params) => } /> +

Showing {getFilteredLength(input, listOfPatients)} of {props.searchablePatients.length} records

+
{displayFilteredPatientList(input, listOfPatients[0])}
); @@ -52,12 +69,11 @@ export default function PatientSearchBar(props) { return element.name.toLowerCase().includes(searchstring); } }); - return ( {filteredListOfPatients.map(patient => { return ( -
+ item.id === patient.id)} @@ -72,7 +88,7 @@ export default function PatientSearchBar(props) { responseExpirationDays={props.responseExpirationDays} defaultUser={props.defaultUser} /> -
+ ); })}
@@ -80,8 +96,8 @@ export default function PatientSearchBar(props) { } return ( -
+ {listOfPatients[0] ? patientSearchBar() : 'loading...'} -
+ ); } \ No newline at end of file diff --git a/src/components/RequestBox/PatientSearchBar/PatientSearchBarStyle.css b/src/components/RequestBox/PatientSearchBar/PatientSearchBarStyle.css index bcd1cac6..4908c658 100644 --- a/src/components/RequestBox/PatientSearchBar/PatientSearchBarStyle.css +++ b/src/components/RequestBox/PatientSearchBar/PatientSearchBarStyle.css @@ -3,10 +3,8 @@ } .search-box { - top: -10; - width: 100%; - margin: 10px auto; - margin-bottom: 50px; + width: 75%; + margin: 0px 10px 25px 20px; } .search-box-container { @@ -15,4 +13,9 @@ display: flex; flex-direction: column; justify-content: space-evenly +} + +.search-header { + display: flex; + align-items: center; } \ No newline at end of file diff --git a/src/components/RequestBox/RequestBox.js b/src/components/RequestBox/RequestBox.js index ea995e28..408367a2 100644 --- a/src/components/RequestBox/RequestBox.js +++ b/src/components/RequestBox/RequestBox.js @@ -1,47 +1,23 @@ -import PersonIcon from '@mui/icons-material/Person'; -import { Box, Button, ButtonGroup, Modal } from '@mui/material'; +import { Button, ButtonGroup } from '@mui/material'; import _ from 'lodash'; import React, { Component } from 'react'; import buildNewRxRequest from '../../util/buildScript.2017071.js'; -import { defaultValues, shortNameMap, types } from '../../util/data'; +import PersonIcon from '@mui/icons-material/Person'; +import MuiAlert from '@mui/material/Alert'; +import Snackbar from '@mui/material/Snackbar'; +import { defaultValues, shortNameMap } from '../../util/data'; import { getAge } from '../../util/fhir'; import { retrieveLaunchContext } from '../../util/util'; +import InProgressFormBox from './InProgressFormBox/InProgressFormBox.js'; import './request.css'; -import PatientSearchBar from './PatientSearchBar/PatientSearchBar.js'; - -const style = { - position: 'absolute', - top: '50%', - left: '50%', - flexDirection: 'column', - width: '80%', - height: '70%', - overflowY: 'scroll', - transform: 'translate(-50%, -50%)', - display: 'flex', - bgcolor: 'background.paper', - border: '2px solid #000', - borderBottom: '2px solid black', - boxShadow: 24, - p: 4, - padding: '50px' -}; export default class RequestBox extends Component { constructor(props) { super(props); this.state = { - openPatient: false, - patientList: [], - patient: {}, - prefetchedResources: new Map(), - codeValues: defaultValues, - code: null, - codeSystem: null, - display: null, - request: {}, gatherCount: 0, - response: {} + response: {}, + open: false }; this.renderRequestResources = this.renderRequestResources.bind(this); @@ -55,27 +31,22 @@ export default class RequestBox extends Component { // TODO - see how to submit response for alternative therapy replaceRequestAndSubmit(request) { - this.setState({ request: request }); - // Submit the cds hook request. + this.props.callback(request,request); // Submit the cds hook request. this.submitOrderSign(request); } componentDidMount() { } - exitSmart = () => { - this.setState({ openPatient: false }); - }; - prepPrefetch() { const preppedResources = new Map(); - Object.keys(this.state.prefetchedResources).forEach(resourceKey => { + Object.keys(this.props.prefetchedResources).forEach(resourceKey => { let resourceList = []; - if (Array.isArray(this.state.prefetchedResources[resourceKey])) { - resourceList = this.state.prefetchedResources[resourceKey].map(resource => { + if (Array.isArray(this.props.prefetchedResources[resourceKey])) { + resourceList = this.props.prefetchedResources[resourceKey].map(resource => { return resource; }); } else { - resourceList = this.state.prefetchedResources[resourceKey]; + resourceList = this.props.prefetchedResources[resourceKey]; } preppedResources.set(resourceKey, resourceList); @@ -84,27 +55,27 @@ export default class RequestBox extends Component { } submitPatientView = () => { - this.props.submitInfo(this.prepPrefetch(), null, this.state.patient, 'patient-view'); + this.props.submitInfo(this.prepPrefetch(), null, this.props.patient, 'patient-view'); }; submitOrderSelect = () => { - if (!_.isEmpty(this.state.request)) { + if (!_.isEmpty(this.props.request)) { this.props.submitInfo( this.prepPrefetch(), - this.state.request, - this.state.patient, + this.props.request, + this.props.patient, 'order-select' ); } }; submitOrderSign = request => { - this.props.submitInfo(this.prepPrefetch(), request, this.state.patient, 'order-sign'); + this.props.submitInfo(this.prepPrefetch(), request, this.props.patient, 'order-sign'); }; submit = () => { - if (!_.isEmpty(this.state.request)) { - this.submitOrderSign(this.state.request); + if (!_.isEmpty(this.props.request)) { + this.submitOrderSign(this.props.request); } }; @@ -115,12 +86,12 @@ export default class RequestBox extends Component { this.state.prefetchCompleted ) { // if the prefetch contains a medicationRequests bundle - if (this.state.prefetchedResources.medicationRequests) { + if (this.props.prefetchedResources.medicationRequests) { this.submitPatientView(); } // we could use this in the future to send order-select //// if the prefetch contains a request - //if (this.state.prefetchedResources.request) { + //if (this.props.prefetchedResources.request) { // this.submitOrderSelect(); //} } @@ -130,52 +101,10 @@ export default class RequestBox extends Component { this.setState({ [elementName]: text }); }; - updateStateList = (elementName, text) => { - this.setState(prevState => { - return { [elementName]: [...prevState[elementName], text] }; - }); - }; - - updateStateMap = (elementName, key, text) => { - this.setState(prevState => { - if (!prevState[elementName][key]) { - prevState[elementName][key] = []; - } - return { [elementName]: { ...prevState[elementName], [key]: text } }; - }); - }; - - clearState = () => { - this.setState({ - prefetchedResources: new Map(), - practitioner: {}, - coverage: {}, - response: {} - }); - }; - - getPatients = () => { - - - this.props.client - .request(this.props.patientFhirQuery, { flat: true }) - .then(result => { - this.setState({ - patientList: result, - openPatient: true - }); - }) - .catch(e => { - this.setState({ - patientList: e - }); - }); - }; - emptyField = (empty); renderPatientInfo() { - const patient = this.state.patient; + const patient = this.props.patient; if (Object.keys(patient).length === 0) { return
; } @@ -201,7 +130,6 @@ export default class RequestBox extends Component { State: {this.state.patientState ? this.state.patientState : this.emptyField}
{this.renderOtherInfo()} - {this.renderQRInfo()}
); } @@ -213,46 +141,20 @@ export default class RequestBox extends Component { Coding
- Code: {this.state.code ? this.state.code : this.emptyField} + Code: {this.props.code ? this.props.code : this.emptyField}
- System: {this.state.codeSystem ? shortNameMap[this.state.codeSystem] : this.emptyField} + System: {this.props.codeSystem ? shortNameMap[this.props.codeSystem] : this.emptyField}
- Display: {this.state.display ? this.state.display : this.emptyField} + Display: {this.props.display ? this.props.display : this.emptyField}
); } - renderQRInfo() { - const qrResponse = this.state.response; - return ( -
- {qrResponse.questionnaire ? ( - <> -
- In Progress Form -
-
- Form: {qrResponse.questionnaire ? qrResponse.questionnaire : this.emptyField} -
-
- Author: {qrResponse.author ? qrResponse.author.reference : this.emptyField} -
-
- Date: {qrResponse.authored ? qrResponse.authored : this.emptyField} -
- - ) : ( -
- )} -
- ); - } - renderPrefetchedResources() { - const prefetchMap = new Map(Object.entries(this.state.prefetchedResources)); + const prefetchMap = new Map(Object.entries(this.props.prefetchedResources)); if (prefetchMap.size > 0) { return this.renderRequestResources(prefetchMap); } @@ -328,7 +230,7 @@ export default class RequestBox extends Component { launchSmartOnFhirApp = () => { console.log('Launch SMART on FHIR App'); - let userId = this.state.prefetchedResources?.practitioner?.id; + let userId = this.props.prefetchedResources?.practitioner?.id; if (!userId) { console.log( 'Practitioner not populated from prefetch, using default from config: ' + @@ -338,12 +240,12 @@ export default class RequestBox extends Component { } let link = { - appContext: 'user=' + userId + '&patient=' + this.state.patient.id, + appContext: 'user=' + userId + '&patient=' + this.props.patient.id, type: 'smart', url: this.props.smartAppUrl }; - retrieveLaunchContext(link, this.state.patient.id, this.props.client.state).then(result => { + retrieveLaunchContext(link, this.props.patient.id, this.props.client.state).then(result => { link = result; console.log(link); // launch the application in a new window @@ -369,10 +271,10 @@ export default class RequestBox extends Component { response = undefined; if (!this.isOrderNotSelected()) { - if (Object.keys(this.state.request).length > 0) { - order = `${this.state.request.resourceType}/${this.state.request.id}`; - if (this.state.request.insurance && this.state.request.insurance.length > 0) { - coverage = `${this.state.request.insurance[0].reference}`; + if (Object.keys(this.props.request).length > 0) { + order = `${this.props.request.resourceType}/${this.props.request.id}`; + if (this.props.request.insurance && this.props.request.insurance.length > 0) { + coverage = `${this.props.request.insurance[0].reference}`; } } } @@ -385,8 +287,8 @@ export default class RequestBox extends Component { } } - if (Object.keys(this.state.response).length > 0) { - response = `QuestionnaireResponse/${this.state.response.id}`; + if (Object.keys(this.props.response).length > 0) { + response = `QuestionnaireResponse/${this.props.response.id}`; } if (order && response) { @@ -403,7 +305,7 @@ export default class RequestBox extends Component { let linkCopy = Object.assign({}, link); - return retrieveLaunchContext(linkCopy, this.state.patient.id, this.props.client.state).then( + return retrieveLaunchContext(linkCopy, this.props.patient.id, this.props.client.state).then( result => { linkCopy = result; return linkCopy; @@ -412,22 +314,24 @@ export default class RequestBox extends Component { } /** - * Send the NewRxRequestMessage to the Pharmacy Information System (PIMS) + * Send NewRx for new Medication to the Pharmacy Information System (PIMS) */ sendRx = e => { - console.log('sendRx: ' + this.props.pimsUrl); + console.log('Sending NewRx to: ' + this.props.pimsUrl); // build the NewRx Message var newRx = buildNewRxRequest( - this.state.prefetchedResources.patient, - this.state.prefetchedResources.practitioner, - this.state.request + this.props.prefetchedResources.patient, + this.props.prefetchedResources.practitioner, + this.props.request ); + + console.log('Prepared NewRx:'); console.log(newRx); + const serializer = new XMLSerializer(); - // send the message to the Pharmacy - this.props.consoleLog('Sending Rx to PIMS', types.info); + // Sending NewRx to the Pharmacy fetch(this.props.pimsUrl, { method: 'POST', //mode: 'no-cors', @@ -438,98 +342,89 @@ export default class RequestBox extends Component { body: serializer.serializeToString(newRx) }) .then(response => { - console.log('sendRx response: '); + console.log('Successfully sent NewRx to PIMS'); console.log(response); - this.props.consoleLog('Successfully sent Rx to PIMS', types.info); + this.handleRxResponse(); }) .catch(error => { - console.log('sendRx error: '); - this.props.consoleLog('Server returned error sending Rx to PIMS: ', types.error); - this.props.consoleLog(error.message); + console.log('sendRx Error - unable to send NewRx to PIMS: '); console.log(error); }); }; isOrderNotSelected() { - return Object.keys(this.state.request).length === 0; + return Object.keys(this.props.request).length === 0; } isPatientNotSelected() { - return Object.keys(this.state.patient).length === 0; + return Object.keys(this.props.patient).length === 0; } + // SnackBar + handleRxResponse = () => this.setState({ open: true }); + + handleClose = () => this.setState({ open: false }); + + render() { const disableSendToCRD = this.isOrderNotSelected() || this.props.loading; - const disableLaunchDTR = !this.state.response.questionnaire; const disableSendRx = this.isOrderNotSelected() || this.props.loading; const disableLaunchSmartOnFhir = this.isPatientNotSelected(); + const { open } = this.state; return (
-
- - - {/* Patient selection pop up and search */} - - {this.state.patientList instanceof Error - ? this.renderError() - : } - - -
- -
- {this.state.patient.id ? ( - Patient ID: {this.state.patient.id} - ) : ( - No patient selected - )} -
-
- {this.renderPatientInfo()} - {this.renderPrefetchedResources()} -
-
-
- {this.state.patient.id ? ( -
- - - - - - + { this.props.patient.id ? ( +
+
+
+ Patient ID: {this.props.patient.id} +
+
+ {this.renderPatientInfo()} + {this.renderPrefetchedResources()} +
+
+
+ {Object.keys(this.props.response).length ? + + : } + + + + + +
) : ( - + )} + + + Success! NewRx Recieved By Pharmacy + +
); } diff --git a/src/components/RequestBox/request.css b/src/components/RequestBox/request.css index 4bac5323..14b6aedd 100644 --- a/src/components/RequestBox/request.css +++ b/src/components/RequestBox/request.css @@ -12,10 +12,10 @@ .request { border: 1px solid black; - height:530px; + height: auto; padding: 10px; border-radius: 5px; - background-color: rgb(248, 248, 248) + background-color: rgb(248, 248, 248); } .select-button{ @@ -123,4 +123,8 @@ .empty-field { color: dimgrey; font-style: italic; +} + +.patient-info { + margin: 5px; } \ No newline at end of file diff --git a/src/components/SMARTBox/PatientBox.js b/src/components/SMARTBox/PatientBox.js index af8001ff..ea337028 100644 --- a/src/components/SMARTBox/PatientBox.js +++ b/src/components/SMARTBox/PatientBox.js @@ -118,7 +118,7 @@ export default class PatientBox extends Component { updateValues(patient) { this.props.callback('patient', patient); - this.props.callback('openPatient', false); + this.props.callback('expanded', false); this.props.clearCallback(); if (this.state.request) { const request = JSON.parse(this.state.request); @@ -134,6 +134,10 @@ export default class PatientBox extends Component { } } else { this.updatePrefetchRequest(null, patient, this.props.defaultUser); + this.props.callback('request', {}); + this.props.callback('code', null); + this.props.callback('codeSystem', null); + this.props.callback('display', null); } if (this.state.response) { @@ -323,7 +327,6 @@ export default class PatientBox extends Component { } getRequests() { - console.log(this.props.client); const patientId = this.props.patient.id; this.getDeviceRequest(patientId); this.getServiceRequest(patientId); diff --git a/src/components/SMARTBox/smart.css b/src/components/SMARTBox/smart.css index 69043330..243de77e 100644 --- a/src/components/SMARTBox/smart.css +++ b/src/components/SMARTBox/smart.css @@ -86,6 +86,7 @@ html{ .select-btn { height: 40px; align-self: center; + margin-top: 25px !important; } .emptyForm { diff --git a/src/containers/Gateway/Gateway.jsx b/src/containers/Gateway/Gateway.jsx new file mode 100644 index 00000000..a360bb36 --- /dev/null +++ b/src/containers/Gateway/Gateway.jsx @@ -0,0 +1,119 @@ +import React, { memo, useState, useEffect } from 'react'; +import FHIR from 'fhirclient'; +import env from 'env-var'; +import { + Button, + FormControl, + FormHelperText, + IconButton, + TextField, + Accordion, + AccordionDetails +} from '@mui/material'; +import Stack from '@mui/material/Stack'; +import Autocomplete from '@mui/material/Autocomplete'; +import useStyles from './styles'; + +const Gateway = props => { + const classes = useStyles(); + const envFhir = env.get('REACT_APP_EHR_SERVER').asString(); + const envClient = env.get('REACT_APP_CLIENT').asString(); + const envScope = env.get('REACT_APP_CLIENT_SCOPES').asString().split(' '); + const [clientId, setClientId] = useState(envClient || ''); + const [fhirUrl, setFhirUrl] = useState(envFhir || ''); + const [scope, _setScope] = useState(envScope || []); + const setScope = value => { + // split by space to facilitate copy/pasting strings of scopes into the input + const sv = value.map(e => { + if (e) { + return e.split(' '); + } + }); + _setScope(sv.flat()); + }; + const submit = event => { + event.preventDefault(); + FHIR.oauth2.authorize({ + clientId: clientId, + scope: scope.join(' '), + redirectUri: props.redirect, + iss: fhirUrl + }); + }; + return ( +
+

Launch Request Generator

+
+ + + { + setFhirUrl(inputValue); + }} + options={[envFhir]} // TODO: can be updated later to include registered iss + renderInput={params => ( + + )} + /> + { + setClientId(inputValue); + }} + options={[envClient]} // TODO: can be updated later to match from iss from register page + renderInput={params => ( + + )} + /> + { + // scopes is the new full list, not the singular new value + setScope(scopes); + }} + defaultValue={['launch']} + renderInput={params => ( + + )} + /> + + + +
+
+ ); +}; + +export default memo(Gateway); diff --git a/src/containers/Gateway/styles.jsx b/src/containers/Gateway/styles.jsx new file mode 100644 index 00000000..ae2b96db --- /dev/null +++ b/src/containers/Gateway/styles.jsx @@ -0,0 +1,27 @@ +import { makeStyles } from '@mui/styles'; +export default makeStyles( + theme => ({ + '@global': { + body: { + backgroundColor: '#fafafa' + } + }, + gatewayDiv: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + padding: '50px', + margin: '10% auto 0 auto', + width: '60%', + backgroundColor: '#fff' + }, + gatewayHeader: { + marginBottom: '25px' + }, + gatewayInput: { + padding: '50px' + } + }), + + { name: 'Gateway', index: 1 } +); diff --git a/src/containers/Index.jsx b/src/containers/Index.jsx index e1374b4f..3b74544b 100644 --- a/src/containers/Index.jsx +++ b/src/containers/Index.jsx @@ -12,13 +12,17 @@ const Index = props => { }); }, []); - return
- { client ? : -
-

Getting Client...

-
- } -
; + return ( +
+ {client ? ( + + ) : ( +
+

Getting Client...

+
+ )} +
+ ); }; export default Index; diff --git a/src/containers/Launch.jsx b/src/containers/Launch.jsx index fb9854c2..0d2ca90b 100644 --- a/src/containers/Launch.jsx +++ b/src/containers/Launch.jsx @@ -1,29 +1,64 @@ -import React, { memo, useState, useEffect } from 'react'; -import FHIR from 'fhirclient'; import env from 'env-var'; import queryString from 'querystring'; +import FHIR from 'fhirclient'; +import React, { memo, useEffect, useState } from 'react'; +import RegisterPage from './register/RegisterPage'; const Launch = props => { + const [content, setContent] = useState( +
+

Launching...

+
+ ); + useEffect(() => { - // this is a workaround for the fhir client not being able to pull values out of the - // hash. Regex will look for different permutations of a hashrouter url /#/launch /#launch #launch /#launch - const params = queryString.parse((window.location.hash || '').replace(/\/?#\/?launch\?/, '')); + smartLaunch(); + }, []); - // if these are null then the client will pull them out of the browsers query string - // so we don't need to do that here. + const smartLaunch = () => { + let clients = JSON.parse(localStorage.getItem('clients') || '[]'); + if (clients.length === 0) { + const defaultClient = env.get('REACT_APP_CLIENT').asString(); + const defaultIss = env.get('REACT_APP_EHR_BASE').asString(); + if (defaultClient && defaultIss) { + clients = [{ client: defaultClient, name: defaultIss }]; + localStorage.setItem('clients', JSON.stringify(clients)); + } + } + const urlSearchString = window.location.search; + const params = queryString.parse((window.location.hash || '').replace(/\/?#\/?launch\?/, '')); const iss = params.iss; + console.log('iss: ' + iss); const launch = params.launch; + console.log('launch: ' + launch); + if (iss) { + const storedClient = clients.find(e => { + return e.name == iss; + }); - FHIR.oauth2.authorize({ - clientId: env.get('REACT_APP_CLIENT').asString(), - scope: env.get('REACT_APP_CLIENT_SCOPES').asString(), - redirectUri: props.redirect, - iss: iss, - launch: launch - }); - }, []); + if (storedClient) { + // found matching iss + const clientId = storedClient.client; + FHIR.oauth2 + .authorize({ + clientId: clientId, + scope: env.get('REACT_APP_CLIENT_SCOPES').asString(), + redirectUri: props.redirect, + iss: iss, + launch: launch + }) + .catch(e => { + console.log(e); + }); + } else { + setContent(); + } + } else { + setContent(
iss not found
); + } + }; - return

Launching...

; + return
{content}
; }; export default memo(Launch); diff --git a/src/containers/RequestBuilder.js b/src/containers/RequestBuilder.js index adecf489..eba3a64c 100644 --- a/src/containers/RequestBuilder.js +++ b/src/containers/RequestBuilder.js @@ -1,29 +1,43 @@ import React, { Component } from 'react'; - +import { Button, Box, IconButton } from '@mui/material'; +import PersonIcon from '@mui/icons-material/Person'; +import RefreshIcon from '@mui/icons-material/Refresh'; import DisplayBox from '../components/DisplayBox/DisplayBox'; -import ConsoleBox from '../components/ConsoleBox/ConsoleBox'; import '../index.css'; -import '../components/ConsoleBox/consoleBox.css'; import SettingsBox from '../components/SettingsBox/SettingsBox'; import RequestBox from '../components/RequestBox/RequestBox'; import buildRequest from '../util/buildRequest.js'; -import { types } from '../util/data.js'; +import { types, defaultValues } from '../util/data.js'; import { createJwt, setupKeys } from '../util/auth'; + import env from 'env-var'; import FHIR from 'fhirclient'; +import Accordion from '@mui/material/Accordion'; +import AccordionSummary from '@mui/material/AccordionSummary'; +import AccordionDetails from '@mui/material/AccordionDetails'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; + +import PatientSearchBar from '../components/RequestBox/PatientSearchBar/PatientSearchBar'; export default class RequestBuilder extends Component { constructor(props) { super(props); this.state = { - keypair: null, loading: false, logs: [], - patient: {}, + patient: {}, + expanded: false, + patientList: [], response: null, + code: null, + codeSystem: null, + display: null, + prefetchedResources: new Map(), + request: {}, showSettings: false, token: null, client: this.props.client, + codeValues: defaultValues, // Configurable values alternativeTherapy: env.get('REACT_APP_ALT_DRUG').asBool(), baseUrl: env.get('REACT_APP_EHR_BASE').asString(), @@ -55,14 +69,11 @@ export default class RequestBuilder extends Component { } componentDidMount() { - const callback = keypair => { - this.setState({ keypair }); - }; - - setupKeys(callback); if (!this.state.client) { this.reconnectEhr(); } else { + // Call patients on load of page + this.getPatients(); this.setState({ baseUrl: this.state.client.state.serverUrl }); this.setState({ ehrUrl: this.state.client.state.serverUrl }); } @@ -90,8 +101,15 @@ export default class RequestBuilder extends Component { updateStateElement = (elementName, text) => { this.setState({ [elementName]: text }); + // if the patientFhirQuery is updated, make a call to get the patients + if (elementName === 'patientFhirQuery') { + setTimeout(() => { + this.getPatients(); + }, 1000); + } }; + timeout = time => { let controller = new AbortController(); setTimeout(() => controller.abort(), time * 1000); @@ -130,19 +148,20 @@ export default class RequestBuilder extends Component { return; } - const createHeaders = () => { - const init = { 'Content-Type': 'application/json' }; - if (this.state.generateJsonToken) { - const jwt = 'Bearer ' + createJwt(this.state.keypair, this.state.baseUrl, cdsUrl); - init.authorization = jwt; - } - return new Headers(init); + let baseUrl = this.state.baseUrl; + + const headers = { + 'Content-Type': 'application/json' }; + if (this.state.generateJsonToken) { + const jwt = 'Bearer ' + createJwt(baseUrl, cdsUrl); + headers.authorization = jwt; + } try { fetch(cdsUrl, { method: 'POST', - headers: createHeaders(), + headers: new Headers(headers), body: JSON.stringify(json_request), signal: this.timeout(10).signal //Timeout set to 10 seconds }) @@ -164,7 +183,7 @@ export default class RequestBuilder extends Component { }) .catch(() => { this.consoleLog('No response received from the server', types.error); - this.setState({ response: null }); + this.setState({ response: {} }); this.setState({ loading: false }); }); } catch (error) { @@ -182,6 +201,48 @@ export default class RequestBuilder extends Component { this.requestBox.current.replaceRequestAndSubmit(resource); } + getPatients = () => { + this.props.client + .request(this.state.patientFhirQuery, { flat: true }) + .then(result => { + this.setState({ + patientList: result, + }); + }) + .catch(e => { + this.setState({ + patientList: e + }); + }); + }; + + updateStateList = (elementName, text) => { + this.setState(prevState => { + return { [elementName]: [...prevState[elementName], text] }; + }); + }; + + updateStateMap = (elementName, key, text) => { + this.setState(prevState => { + if (!prevState[elementName][key]) { + prevState[elementName][key] = []; + } + return { [elementName]: { ...prevState[elementName], [key]: text } }; + }); + }; + + clearState = () => { + this.setState({ + prefetchedResources: new Map(), + practitioner: {}, + coverage: {}, + response: {} + }); + }; + handleChange = () => (event, isExpanded) => { + this.setState({ expanded: isExpanded ? true: false}); + }; + render() { return (
@@ -203,15 +264,66 @@ export default class RequestBuilder extends Component { Reconnect EHR
-
-
+
+ {/*
*/} {this.state.showSettings && ( - - )} +
+ +
+ )} +
+
+ + } + aria-controls="panel1a-content" + id="panel1a-header" + style={{marginLeft: '45%'}} + > + + + + {this.state.patientList.length > 0 && this.state.expanded ? +
+ + {this.state.patientList instanceof Error + ? this.renderError() + : } + +
+ : + } + +
+
+ this.getPatients()} + size="large" + > + + +
+
{/*for the ehr launch */}
-
- -
-
-
-
diff --git a/src/containers/register/RegisterPage.js b/src/containers/register/RegisterPage.js new file mode 100644 index 00000000..a38d4327 --- /dev/null +++ b/src/containers/register/RegisterPage.js @@ -0,0 +1,122 @@ +import CloseIcon from '@mui/icons-material/Close'; +import { + Box, + Button, + Card, + CardContent, + FormControl, + FormHelperText, + IconButton, + TextField, + Typography +} from '@mui/material'; +import React, { useState } from 'react'; +import './RegisterPageStyle.css'; + +export default function RegisterPage(props) { + const [clientId, setClientId] = useState(''); + const [fhirUrl, setFhirUrl] = useState(props.fhirUrl || ''); + + const [currentClients, setCurrentClients] = useState( + JSON.parse(localStorage.getItem('clients') || '[]') + ); + + function deleteClient(client) { + const newClients = currentClients.filter( + c => !(c.name === client.name && c.client === client.client) + ); + localStorage.setItem('clients', JSON.stringify(newClients)); + setCurrentClients(newClients); + } + + function submit(event) { + console.log('new selection add to LS'); + const newClients = [...currentClients, { name: fhirUrl, client: clientId }]; + setCurrentClients(newClients); + localStorage.setItem('clients', JSON.stringify(newClients)); + if (props.callback) { + event.preventDefault(); + props.callback(); // try launching again + } + return false; + } + + return ( + <> + + + +

Client ID Registration

+ {props.callback ?
Client ID not found. Please register the client ID.
: ''} + + Request Generator + +

+

+
+ + { + setClientId(e.target.value); + }} + /> + + Clients must be registered with the FHIR server out of band. + + + + { + setFhirUrl(e.target.value); + }} + /> + + The ISS is the base url of the FHIR server. + + + +
+
+
+

+ + + Existing Client Ids: + + + {currentClients.map((client, index) => { + return ( +
+ + {client.name}: {client.client} + + { + deleteClient(client); + }} + > + + +
+ ); + })} +
+
+ + ); +} diff --git a/src/containers/register/RegisterPageStyle.css b/src/containers/register/RegisterPageStyle.css new file mode 100644 index 00000000..ad715639 --- /dev/null +++ b/src/containers/register/RegisterPageStyle.css @@ -0,0 +1,19 @@ +body { + background-color: #F5F5F7; +} + +.registerContainer { + max-width:800px; + margin: 0; + position: absolute; + top: 40%; + left: 50%; + transform: translate(-50%, -40%); +} + +.clientIds { + text-align: center; +} +.clientIdList { + text-align: center; +} \ No newline at end of file diff --git a/src/index.css b/src/index.css index 5ab4fed4..19229868 100644 --- a/src/index.css +++ b/src/index.css @@ -15,8 +15,6 @@ body { box-shadow: none; } - - .floating-label { position: absolute; pointer-events: none; @@ -307,11 +305,12 @@ input:not(:focus):not([value=""]):valid ~ .floating-label{ } .nav-header{ - margin-bottom: 10px; - height: 55px; - padding:10px; - border-bottom: 1px solid black; - background-color: #005B94; + margin-bottom: 10px; + display: flow; + height: 55px; + padding:10px; + border-bottom: 1px solid black; + background-color: #005B94; } .loading{ @@ -325,4 +324,9 @@ input:not(:focus):not([value=""]):valid ~ .floating-label{ .title { margin-bottom: 65px; +} + +.settings-box { + width: 50%; + margin-left: 20px; } \ No newline at end of file diff --git a/src/keys/crdPrivateKey.js b/src/keys/crdPrivateKey.js new file mode 100644 index 00000000..10b2c799 --- /dev/null +++ b/src/keys/crdPrivateKey.js @@ -0,0 +1,6 @@ +const privKey = `-----BEGIN PRIVATE KEY----- +ME4CAQAwEAYHKoZIzj0CAQYFK4EEACIENzA1AgEBBDBuhq1aqZUJC+bzBqkLtcgX +fejJ2PNdStvozDPhdyWRccJIRi13GPfjkszAUlAC+s0= +-----END PRIVATE KEY-----`; + +export default privKey; diff --git a/src/util/auth.js b/src/util/auth.js index d0559037..0258583b 100644 --- a/src/util/auth.js +++ b/src/util/auth.js @@ -1,16 +1,8 @@ +import privKey from '../keys/crdPrivateKey.js'; import KJUR, { KEYUTIL } from 'jsrsasign'; +import { v4 as uuidv4 } from 'uuid'; import env from 'env-var'; -function makeid() { - var text = []; - var possible = '---ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - - for (var i = 0; i < 25; i++) - text.push(possible.charAt(Math.floor(Math.random() * possible.length))); - - return text.join(''); -} - function login() { const tokenUrl = env.get('REACT_APP_AUTH').asString() + @@ -41,65 +33,30 @@ function login() { }); } -function createJwt(keypair, baseUrl, cdsUrl) { - console.log('creating jwt'); - const currentTime = KJUR.jws.IntDate.get('now'); - const endTime = KJUR.jws.IntDate.get('now + 1day'); - const kid = KJUR.jws.JWS.getJWKthumbprint(keypair.public); +/** + * Generates a JWT for a CDS service call, given the audience (the URL endpoint). The JWT is signed using a private key stored on the repository. + * + * Note: In production environments, the JWT should be signed on a secured server for best practice. The private key is exposed on the repository + * as it is an open source client-side project and tool. + * @param {*} audience - URL endpoint acting as the audience + */ +function createJwt(baseUrl, audience) { + const jwtPayload = JSON.stringify({ + iss: baseUrl, + aud: audience, + exp: Math.round(Date.now() / 1000 + 300), + iat: Math.round(Date.now() / 1000), + jti: uuidv4() + }); - const header = { - alg: 'RS256', + const jwtHeader = JSON.stringify({ + alg: 'ES384', typ: 'JWT', - kid: kid, + kid: 'zGe023HzCFfY7NPb04EGvRDP1oYsTOtLNCNjDgr66AI', jku: env.get('REACT_APP_PUBLIC_KEYS').asString() - }; - const body = { - iss: baseUrl, - aud: cdsUrl, - iat: currentTime, - exp: endTime, - jti: makeid() - }; - - var sJWT = KJUR.jws.JWS.sign( - 'RS256', - JSON.stringify(header), - JSON.stringify(body), - keypair.private - ); - return sJWT; -} - -function setupKeys(callback) { - const { prvKeyObj, pubKeyObj } = KEYUTIL.generateKeypair('RSA', 2048); - const jwkPrv2 = KEYUTIL.getJWKFromKey(prvKeyObj); - const jwkPub2 = KEYUTIL.getJWKFromKey(pubKeyObj); - const kid = KJUR.jws.JWS.getJWKthumbprint(jwkPub2); - - const keypair = { - private: jwkPrv2, - public: jwkPub2, - kid: kid - }; - - const pubPem = { - pem: jwkPub2, - id: kid - }; + }); - fetch(`${env.get('REACT_APP_PUBLIC_KEYS').asString()}/`, { - body: JSON.stringify(pubPem), - headers: { - 'Content-Type': 'application/json' - }, - method: 'POST' - }) - .then(response => { - callback(keypair); - }) - .catch(error => { - console.log(error); - }); + return KJUR.jws.JWS.sign(null, jwtHeader, jwtPayload, privKey); } -export { createJwt, login, setupKeys }; +export { createJwt, login };