From 4758f2adccaa5740b95ea84e16e438b78e38d6e9 Mon Sep 17 00:00:00 2001 From: Jason Kulatunga Date: Tue, 9 May 2023 23:34:23 -0700 Subject: [PATCH] WIP trying to get callbacks working. --- .../medical-sources-connected.component.ts | 220 +++++++++++++ .../medical-sources.component.ts | 306 ++---------------- 2 files changed, 248 insertions(+), 278 deletions(-) diff --git a/frontend/src/app/components/medical-sources-connected/medical-sources-connected.component.ts b/frontend/src/app/components/medical-sources-connected/medical-sources-connected.component.ts index 77dc6d99..bbe004cf 100644 --- a/frontend/src/app/components/medical-sources-connected/medical-sources-connected.component.ts +++ b/frontend/src/app/components/medical-sources-connected/medical-sources-connected.component.ts @@ -5,6 +5,11 @@ import {ModalDismissReasons, NgbModal} from '@ng-bootstrap/ng-bootstrap'; import {FastenApiService} from '../../services/fasten-api.service'; import {forkJoin} from 'rxjs'; import {LighthouseService} from '../../services/lighthouse.service'; +import {LighthouseSourceMetadata} from '../../models/lighthouse/lighthouse-source-metadata'; +import {ToastNotification, ToastType} from '../../models/fasten/toast'; +import {ToastService} from '../../services/toast.service'; +import {ActivatedRoute, Router} from '@angular/router'; +import {Location} from '@angular/common'; @Component({ selector: 'app-medical-sources-connected', @@ -24,6 +29,10 @@ export class MedicalSourcesConnectedComponent implements OnInit { private lighthouseApi: LighthouseService, private fastenApi: FastenApiService, private modalService: NgbModal, + private toastService: ToastService, + private activatedRoute: ActivatedRoute, + private router: Router, + private location: Location, ) { } ngOnInit(): void { @@ -39,8 +48,219 @@ export class MedicalSourcesConnectedComponent implements OnInit { } }) }) + + const callbackSourceType = this.activatedRoute.snapshot.paramMap.get('source_type') + if(callbackSourceType) { + console.log("handle callback redirect from source") + this.status[callbackSourceType] = "token" + + //the structure of "availableSourceList" vs "connectedSourceList" sources is slightly different, + //connectedSourceList contains a "source" field. The this.fastenApi.createSource() call in the callback function will set it. + this.lighthouseApi.getLighthouseSource(callbackSourceType) + .then((metadata) => { + this.connectedSourceList.push({metadata: metadata}) + return this.callback(callbackSourceType) + }) + .then(console.log) + } + + } + /** + * if the user is redirected to this page from the lighthouse, we'll need to process the "code" to retrieve the access token & refresh token. + * @param sourceType + */ + public async callback(sourceType: string) { + + //get the source metadata again + await this.lighthouseApi.getLighthouseSource(sourceType) + .then(async (sourceMetadata: LighthouseSourceMetadata) => { + + //get required parameters from the URI and local storage + const callbackUrlParts = new URL(window.location.href) + const fragmentParams = new URLSearchParams(callbackUrlParts.hash.substring(1)) + const callbackCode = callbackUrlParts.searchParams.get("code") || fragmentParams.get("code") + const callbackState = callbackUrlParts.searchParams.get("state") || fragmentParams.get("state") + const callbackError = callbackUrlParts.searchParams.get("error") || fragmentParams.get("error") + const callbackErrorDescription = callbackUrlParts.searchParams.get("error_description") || fragmentParams.get("error_description") + + //reset the url, removing the params and fragment from the current url. + const urlTree = this.router.createUrlTree(["/sources"],{ + relativeTo: this.activatedRoute, + }); + this.location.replaceState(urlTree.toString()); + + const expectedSourceStateInfo = JSON.parse(localStorage.getItem(callbackState)) + localStorage.removeItem(callbackState) + + if(callbackError && !callbackCode){ + //TOOD: print this message in the UI + let errMsg = "an error occurred while authenticating to this source. Please try again later" + console.error(errMsg, callbackErrorDescription) + throw new Error(errMsg) + } + + console.log("callback code:", callbackCode) + this.status[sourceType] = "token" + + let payload: any + payload = await this.lighthouseApi.swapOauthToken(sourceType, sourceMetadata,expectedSourceStateInfo, callbackCode) + + if(!payload.access_token || payload.error){ + //if the access token is not set, then something is wrong, + let errMsg = payload.error || "unable to retrieve access_token" + console.error(errMsg) + throw new Error(errMsg) + } + + //If payload.patient is not set, make sure we extract the patient ID from the id_token or make an introspection req + if(!payload.patient && payload.id_token){ + // + console.log("NO PATIENT ID present, decoding jwt to extract patient") + //const introspectionResp = await Oauth.introspectionRequest(as, client, payload.access_token) + //console.log(introspectionResp) + let decodedIdToken = this.jwtDecode(payload.id_token) + //nextGen uses fhirUser instead of profile. + payload.patient = decodedIdToken["profile"] || decodedIdToken["fhirUser"] + + if(payload.patient){ + payload.patient = payload.patient.replace(/^(Patient\/)/,'') + } + + } + + + + //Create FHIR Client + + const dbSourceCredential = new Source({ + source_type: sourceType, + + authorization_endpoint: sourceMetadata.authorization_endpoint, + token_endpoint: sourceMetadata.token_endpoint, + introspection_endpoint: sourceMetadata.introspection_endpoint, + userinfo_endpoint: sourceMetadata.userinfo_endpoint, + api_endpoint_base_url: sourceMetadata.api_endpoint_base_url, + client_id: sourceMetadata.client_id, + redirect_uri: sourceMetadata.redirect_uri, + scopes_supported: sourceMetadata.scopes_supported, + issuer: sourceMetadata.issuer, + grant_types_supported: sourceMetadata.grant_types_supported, + response_types_supported: sourceMetadata.response_types_supported, + aud: sourceMetadata.aud, + code_challenge_methods_supported: sourceMetadata.code_challenge_methods_supported, + confidential: sourceMetadata.confidential, + cors_relay_required: sourceMetadata.cors_relay_required, + + patient: payload.patient, + access_token: payload.access_token, + refresh_token: payload.refresh_token, + id_token: payload.id_token, + + // @ts-ignore - in some cases the getAccessTokenExpiration is a string, which cases failures to store Source in db. + expires_at: parseInt(this.getAccessTokenExpiration(payload)), + }) + + this.fastenApi.createSource(dbSourceCredential) + .subscribe((resp) => { + // const sourceSyncMessage = JSON.parse(msg) as SourceSyncMessage + delete this.status[sourceType] + // window.location.reload(); + // this.connectedSourceList. + + //find the index of the "inprogress" source in the connected List, and then add this source to its source metadata. + let foundSource = this.connectedSourceList.findIndex((item) => item.metadata.source_type == sourceType) + this.connectedSourceList[foundSource].source = resp.source + + console.log("source sync-all response:", resp.summary) + + const toastNotification = new ToastNotification() + toastNotification.type = ToastType.Success + toastNotification.message = `Successfully connected ${sourceType}` + + // const upsertSummary = sourceSyncMessage.response as UpsertSummary + // if(upsertSummary && upsertSummary.totalResources != upsertSummary.updatedResources.length){ + // toastNotification.message += `\n (total: ${upsertSummary.totalResources}, updated: ${upsertSummary.updatedResources.length})` + // } else if(upsertSummary){ + // toastNotification.message += `\n (total: ${upsertSummary.totalResources})` + // } + + this.toastService.show(toastNotification) + }, + (err) => { + delete this.status[sourceType] + // window.location.reload(); + + const toastNotification = new ToastNotification() + toastNotification.type = ToastType.Error + toastNotification.message = `An error occurred while accessing ${sourceType}: ${err}` + toastNotification.autohide = false + this.toastService.show(toastNotification) + console.error(err) + }); + }) + .catch((err) => { + delete this.status[sourceType] + // window.location.reload(); + + const toastNotification = new ToastNotification() + toastNotification.type = ToastType.Error + toastNotification.message = `An error occurred while accessing ${sourceType}: ${err}` + toastNotification.autohide = false + this.toastService.show(toastNotification) + console.error(err) + }) + } + + + + /** + * https://github.com/smart-on-fhir/client-js/blob/8f64b770dbcd0abd30646e239cd446dfa4d831f6/src/lib.ts#L311 + * Decodes a JWT token and returns it's body. + * @param token The token to read + * @param env An `Adapter` or any other object that has an `atob` method + * @category Utility + */ + private jwtDecode(token: string): Record | null + { + const payload = token.split(".")[1]; + return payload ? JSON.parse(atob(payload)) : null; + } + + /** + * https://github.com/smart-on-fhir/client-js/blob/8f64b770dbcd0abd30646e239cd446dfa4d831f6/src/lib.ts#L334 + * Given a token response, computes and returns the expiresAt timestamp. + * Note that this should only be used immediately after an access token is + * received, otherwise the computed timestamp will be incorrect. + * @param tokenResponse + * @param env + */ + private getAccessTokenExpiration(tokenResponse: any): number + { + const now = Math.floor(Date.now() / 1000); + + // Option 1 - using the expires_in property of the token response + if (tokenResponse.expires_in) { + return now + tokenResponse.expires_in; + } + + // Option 2 - using the exp property of JWT tokens (must not assume JWT!) + if (tokenResponse.access_token) { + let tokenBody = this.jwtDecode(tokenResponse.access_token); + if (tokenBody && tokenBody['exp']) { + return tokenBody['exp']; + } + } + + // Option 3 - if none of the above worked set this to 5 minutes after now + return now + 300; + } + + + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // Modal Window Functions + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// public openModal(contentModalRef, sourceListItem: SourceListItem) { if(this.status[sourceListItem.metadata.source_type] || !sourceListItem.source){ diff --git a/frontend/src/app/pages/medical-sources/medical-sources.component.ts b/frontend/src/app/pages/medical-sources/medical-sources.component.ts index da1ca93a..253148b6 100644 --- a/frontend/src/app/pages/medical-sources/medical-sources.component.ts +++ b/frontend/src/app/pages/medical-sources/medical-sources.component.ts @@ -4,11 +4,8 @@ import {FastenApiService} from '../../services/fasten-api.service'; import {LighthouseSourceMetadata} from '../../models/lighthouse/lighthouse-source-metadata'; import {Source} from '../../models/fasten/source'; import {MetadataSource} from '../../models/fasten/metadata-source'; -import {ModalDismissReasons, NgbModal} from '@ng-bootstrap/ng-bootstrap'; -import {ActivatedRoute, Router} from '@angular/router'; -import {Location} from '@angular/common'; -import {ToastService} from '../../services/toast.service'; -import {ToastNotification, ToastType} from '../../models/fasten/toast'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {ActivatedRoute} from '@angular/router'; import {environment} from '../../../environments/environment'; import {BehaviorSubject, forkJoin, Observable, Subject} from 'rxjs'; import { @@ -79,8 +76,6 @@ export class MedicalSourcesComponent implements OnInit { private lighthouseApi: LighthouseService, private fastenApi: FastenApiService, private activatedRoute: ActivatedRoute, - private location: Location, - private toastService: ToastService, private filterService: MedicalSourcesFilterService, private modalService: NgbModal, ) { } @@ -91,88 +86,40 @@ export class MedicalSourcesComponent implements OnInit { //TODO: handle Callbacks from the source connect window const callbackSourceType = this.activatedRoute.snapshot.paramMap.get('source_type') if(callbackSourceType){ - console.error("TODO! handle callback redirect from source") - } else { - //we're not in a callback redirect, lets load the sources - if(this.activatedRoute.snapshot.queryParams['query']){ - this.searchTermUpdate.next(this.activatedRoute.snapshot.queryParams['query']) + //move this source from available to connected (with a progress bar) + //remove item from available sources list, add to connected sources. + let inProgressAvailableIndex = this.availableSourceList.findIndex((item) => item.metadata.source_type == callbackSourceType) + if(inProgressAvailableIndex > -1){ + let sourcesInProgress = this.availableSourceList.splice(inProgressAvailableIndex, 1); } - - //changing the route should trigger a query - this.activatedRoute.queryParams - .subscribe(params => { - console.log("QUERY PARAMS CHANGED ON ROUTE", params); // {order: "popular"} - var updatedForm = this.filterService.parseQueryParams(params); - - //this is a "breaking change" to the filter values, causing a reset and a new query - this.availableSourceList = [] - this.resultLimits.totalItems = 0 - this.resultLimits.scrollComplete = false - this.filterService.resetControl("categories") - // this.filterService.filterForm.setControl("categories", this.{: {}}, { emitEvent: false}) - - //update the form with data from route (don't emit a new patch event), then submit query - var searchObservable = this.querySources(this.filterService.toMedicalSourcesFilter(updatedForm)); - searchObservable.subscribe(null, null, () => { - this.filterForm.patchValue(updatedForm, { emitEvent: false}); - }) - }); + } + //we're not in a callback redirect, lets load the sources + if(this.activatedRoute.snapshot.queryParams['query']){ + this.searchTermUpdate.next(this.activatedRoute.snapshot.queryParams['query']) } + //changing the route should trigger a query + this.activatedRoute.queryParams + .subscribe(params => { + console.log("QUERY PARAMS CHANGED ON ROUTE", params); // {order: "popular"} + var updatedForm = this.filterService.parseQueryParams(params); + //this is a "breaking change" to the filter values, causing a reset and a new query + this.availableSourceList = [] + this.resultLimits.totalItems = 0 + this.resultLimits.scrollComplete = false + this.filterService.resetControl("categories") + // this.filterService.filterForm.setControl("categories", this.{: {}}, { emitEvent: false}) - /// OLD CODE - Should be refactored + //update the form with data from route (don't emit a new patch event), then submit query + var searchObservable = this.querySources(this.filterService.toMedicalSourcesFilter(updatedForm)); + searchObservable.subscribe(null, null, () => { + this.filterForm.patchValue(updatedForm, { emitEvent: false}); + }) + }); - - - - // this.loading = true - // forkJoin([this.lighthouseApi.findLighthouseSources("", "", this.showHidden), this.fastenApi.getSources()]).subscribe(results => { - // this.loading = false - // - // //handle connected sources sources - // const connectedSources = results[1] as Source[] - // forkJoin(connectedSources.map((source) => this.lighthouseApi.getLighthouseSource(source.source_type))).subscribe((connectedMetadata) => { - // for(const ndx in connectedSources){ - // this.connectedSourceList.push({source: connectedSources[ndx], metadata: connectedMetadata[ndx]}) - // } - // }) - // - // - // //handle source metadata map response - // this.populateAvailableSourceList(results[0] as LighthouseSourceSearch) - // - // - // //check if we've just started connecting a "source_type" - // const callbackSourceType = this.route.snapshot.paramMap.get('source_type') - // if(callbackSourceType){ - // this.status[callbackSourceType] = "token" - // - // //move this source from available to connected (with a progress bar) - // //remove item from available sources list, add to connected sources. - // let inProgressAvailableIndex = this.availableSourceList.findIndex((item) => item.metadata.source_type == callbackSourceType) - // if(inProgressAvailableIndex > -1){ - // let sourcesInProgress = this.availableSourceList.splice(inProgressAvailableIndex, 1); - // - // } - // - // //the structure of "availableSourceList" vs "connectedSourceList" sources is slightly different, - // //connectedSourceList contains a "source" field. The this.fastenApi.createSource() call in the callback function will set it. - // this.lighthouseApi.getLighthouseSource(callbackSourceType) - // .then((metadata) => { - // this.connectedSourceList.push({metadata: metadata}) - // return this.callback(callbackSourceType) - // }) - // .then(console.log) - // } - // - // }, err => { - // this.loading = false - // }) - // - // //register a callback for when the search box content changes this.searchTermUpdate .pipe( @@ -336,153 +283,6 @@ export class MedicalSourcesComponent implements OnInit { } - // - // /** - // * if the user is redirected to this page from the lighthouse, we'll need to process the "code" to retrieve the access token & refresh token. - // * @param sourceType - // */ - // public async callback(sourceType: string) { - // - // //get the source metadata again - // await this.lighthouseApi.getLighthouseSource(sourceType) - // .then(async (sourceMetadata: LighthouseSourceMetadata) => { - // - // //get required parameters from the URI and local storage - // const callbackUrlParts = new URL(window.location.href) - // const fragmentParams = new URLSearchParams(callbackUrlParts.hash.substring(1)) - // const callbackCode = callbackUrlParts.searchParams.get("code") || fragmentParams.get("code") - // const callbackState = callbackUrlParts.searchParams.get("state") || fragmentParams.get("state") - // const callbackError = callbackUrlParts.searchParams.get("error") || fragmentParams.get("error") - // const callbackErrorDescription = callbackUrlParts.searchParams.get("error_description") || fragmentParams.get("error_description") - // - // //reset the url, removing the params and fragment from the current url. - // const urlTree = this.router.createUrlTree(["/sources"],{ - // relativeTo: this.route, - // }); - // this.location.replaceState(urlTree.toString()); - // - // const expectedSourceStateInfo = JSON.parse(localStorage.getItem(callbackState)) - // localStorage.removeItem(callbackState) - // - // if(callbackError && !callbackCode){ - // //TOOD: print this message in the UI - // let errMsg = "an error occurred while authenticating to this source. Please try again later" - // console.error(errMsg, callbackErrorDescription) - // throw new Error(errMsg) - // } - // - // console.log("callback code:", callbackCode) - // this.status[sourceType] = "token" - // - // let payload: any - // payload = await this.lighthouseApi.swapOauthToken(sourceType, sourceMetadata,expectedSourceStateInfo, callbackCode) - // - // if(!payload.access_token || payload.error){ - // //if the access token is not set, then something is wrong, - // let errMsg = payload.error || "unable to retrieve access_token" - // console.error(errMsg) - // throw new Error(errMsg) - // } - // - // //If payload.patient is not set, make sure we extract the patient ID from the id_token or make an introspection req - // if(!payload.patient && payload.id_token){ - // // - // console.log("NO PATIENT ID present, decoding jwt to extract patient") - // //const introspectionResp = await Oauth.introspectionRequest(as, client, payload.access_token) - // //console.log(introspectionResp) - // let decodedIdToken = this.jwtDecode(payload.id_token) - // //nextGen uses fhirUser instead of profile. - // payload.patient = decodedIdToken["profile"] || decodedIdToken["fhirUser"] - // - // if(payload.patient){ - // payload.patient = payload.patient.replace(/^(Patient\/)/,'') - // } - // - // } - // - // - // - // //Create FHIR Client - // - // const dbSourceCredential = new Source({ - // source_type: sourceType, - // - // authorization_endpoint: sourceMetadata.authorization_endpoint, - // token_endpoint: sourceMetadata.token_endpoint, - // introspection_endpoint: sourceMetadata.introspection_endpoint, - // userinfo_endpoint: sourceMetadata.userinfo_endpoint, - // api_endpoint_base_url: sourceMetadata.api_endpoint_base_url, - // client_id: sourceMetadata.client_id, - // redirect_uri: sourceMetadata.redirect_uri, - // scopes_supported: sourceMetadata.scopes_supported, - // issuer: sourceMetadata.issuer, - // grant_types_supported: sourceMetadata.grant_types_supported, - // response_types_supported: sourceMetadata.response_types_supported, - // aud: sourceMetadata.aud, - // code_challenge_methods_supported: sourceMetadata.code_challenge_methods_supported, - // confidential: sourceMetadata.confidential, - // cors_relay_required: sourceMetadata.cors_relay_required, - // - // patient: payload.patient, - // access_token: payload.access_token, - // refresh_token: payload.refresh_token, - // id_token: payload.id_token, - // - // // @ts-ignore - in some cases the getAccessTokenExpiration is a string, which cases failures to store Source in db. - // expires_at: parseInt(this.getAccessTokenExpiration(payload)), - // }) - // - // this.fastenApi.createSource(dbSourceCredential) - // .subscribe((resp) => { - // // const sourceSyncMessage = JSON.parse(msg) as SourceSyncMessage - // delete this.status[sourceType] - // // window.location.reload(); - // // this.connectedSourceList. - // - // //find the index of the "inprogress" source in the connected List, and then add this source to its source metadata. - // let foundSource = this.connectedSourceList.findIndex((item) => item.metadata.source_type == sourceType) - // this.connectedSourceList[foundSource].source = resp.source - // - // console.log("source sync-all response:", resp.summary) - // - // const toastNotification = new ToastNotification() - // toastNotification.type = ToastType.Success - // toastNotification.message = `Successfully connected ${sourceType}` - // - // // const upsertSummary = sourceSyncMessage.response as UpsertSummary - // // if(upsertSummary && upsertSummary.totalResources != upsertSummary.updatedResources.length){ - // // toastNotification.message += `\n (total: ${upsertSummary.totalResources}, updated: ${upsertSummary.updatedResources.length})` - // // } else if(upsertSummary){ - // // toastNotification.message += `\n (total: ${upsertSummary.totalResources})` - // // } - // - // this.toastService.show(toastNotification) - // }, - // (err) => { - // delete this.status[sourceType] - // // window.location.reload(); - // - // const toastNotification = new ToastNotification() - // toastNotification.type = ToastType.Error - // toastNotification.message = `An error occurred while accessing ${sourceType}: ${err}` - // toastNotification.autohide = false - // this.toastService.show(toastNotification) - // console.error(err) - // }); - // }) - // .catch((err) => { - // delete this.status[sourceType] - // // window.location.reload(); - // - // const toastNotification = new ToastNotification() - // toastNotification.type = ToastType.Error - // toastNotification.message = `An error occurred while accessing ${sourceType}: ${err}` - // toastNotification.autohide = false - // this.toastService.show(toastNotification) - // console.error(err) - // }) - // } - /** * this function is used to process manually "uploaded" FHIR bundle files, adding them to the database. @@ -504,54 +304,4 @@ export class MedicalSourcesComponent implements OnInit { - - /////////////////////////////////////////////////////////////////////////////////////// - // Private - /////////////////////////////////////////////////////////////////////////////////////// - - - - - /** - * https://github.com/smart-on-fhir/client-js/blob/8f64b770dbcd0abd30646e239cd446dfa4d831f6/src/lib.ts#L311 - * Decodes a JWT token and returns it's body. - * @param token The token to read - * @param env An `Adapter` or any other object that has an `atob` method - * @category Utility - */ - private jwtDecode(token: string): Record | null - { - const payload = token.split(".")[1]; - return payload ? JSON.parse(atob(payload)) : null; - } - - /** - * https://github.com/smart-on-fhir/client-js/blob/8f64b770dbcd0abd30646e239cd446dfa4d831f6/src/lib.ts#L334 - * Given a token response, computes and returns the expiresAt timestamp. - * Note that this should only be used immediately after an access token is - * received, otherwise the computed timestamp will be incorrect. - * @param tokenResponse - * @param env - */ - private getAccessTokenExpiration(tokenResponse: any): number - { - const now = Math.floor(Date.now() / 1000); - - // Option 1 - using the expires_in property of the token response - if (tokenResponse.expires_in) { - return now + tokenResponse.expires_in; - } - - // Option 2 - using the exp property of JWT tokens (must not assume JWT!) - if (tokenResponse.access_token) { - let tokenBody = this.jwtDecode(tokenResponse.access_token); - if (tokenBody && tokenBody['exp']) { - return tokenBody['exp']; - } - } - - // Option 3 - if none of the above worked set this to 5 minutes after now - return now + 300; - } - }