Merge pull request #8 from fastenhealth/encryption
This commit is contained in:
commit
53c0c66ceb
|
@ -142,7 +142,7 @@ The Fasten source code is organized into a handful of important folders, which w
|
|||
│ │ │ │ └── source-detail # 2 column page displaying FHIR counts, and table listing FHIR resources for selected type
|
||||
│ │ │ ├── services
|
||||
│ │ │ │ ├── auth-interceptor.service.ts # service that looks for 401/403 API responses and triggers Logout
|
||||
│ │ │ │ ├── can-activate.auth-guard.ts # service that checks if user is logged in
|
||||
│ │ │ │ ├── is-authenticated-auth-guard.ts # service that checks if user is logged in
|
||||
│ │ │ │ ├── fasten-api.service.ts # api service, used to commnunicate with Go backend (WILL BE REMOVED)
|
||||
│ │ │ │ ├── fasten-db.service.ts # db service, used to communicate with CouchDB database
|
||||
│ │ │ │ ├── lighthouse.service.ts # api service, used to communicate with auth-gateway (Lighthouse)
|
||||
|
|
|
@ -33,7 +33,9 @@
|
|||
"chart.js": "2.9.4",
|
||||
"crypto-pouch": "^4.0.1",
|
||||
"fhirclient": "^2.5.1",
|
||||
"garbados-crypt": "^3.0.0-beta",
|
||||
"humanize-duration": "^3.27.3",
|
||||
"idb": "^7.1.0",
|
||||
"moment": "^2.29.4",
|
||||
"ng2-charts": "^2.3.0",
|
||||
"ngx-dropzone": "^3.1.0",
|
||||
|
@ -45,6 +47,7 @@
|
|||
"pouchdb-find": "^7.3.0",
|
||||
"pouchdb-upsert": "^2.2.0",
|
||||
"rxjs": "~6.5.4",
|
||||
"transform-pouch": "^2.0.0",
|
||||
"tslib": "^2.0.0",
|
||||
"uuid": "^9.0.0",
|
||||
"zone.js": "~0.11.8"
|
||||
|
|
|
@ -7,8 +7,10 @@ import { MedicalSourcesComponent } from './pages/medical-sources/medical-sources
|
|||
import {ResourceDetailComponent} from './pages/resource-detail/resource-detail.component';
|
||||
import {AuthSigninComponent} from './pages/auth-signin/auth-signin.component';
|
||||
import {AuthSignupComponent} from './pages/auth-signup/auth-signup.component';
|
||||
import {CanActivateAuthGuard} from './services/can-activate.auth-guard';
|
||||
import {IsAuthenticatedAuthGuard} from './auth-guards/is-authenticated-auth-guard';
|
||||
import {EncryptionEnabledAuthGuard} from './auth-guards/encryption-enabled.auth-guard';
|
||||
import {SourceDetailComponent} from './pages/source-detail/source-detail.component';
|
||||
import {EncryptionManagerComponent} from './pages/encryption-manager/encryption-manager.component';
|
||||
|
||||
const routes: Routes = [
|
||||
|
||||
|
@ -16,12 +18,13 @@ const routes: Routes = [
|
|||
{ path: 'auth/signup', component: AuthSignupComponent },
|
||||
|
||||
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
|
||||
{ path: 'dashboard', component: DashboardComponent, canActivate: [ CanActivateAuthGuard] },
|
||||
{ path: 'source/:source_id', component: SourceDetailComponent, canActivate: [ CanActivateAuthGuard] },
|
||||
{ path: 'resource/:resource_id', component: ResourceDetailComponent, canActivate: [ CanActivateAuthGuard] },
|
||||
{ path: 'sources', component: MedicalSourcesComponent, canActivate: [ CanActivateAuthGuard] },
|
||||
{ path: 'sources/callback/:source_type', component: MedicalSourcesComponent, canActivate: [ CanActivateAuthGuard] },
|
||||
{ path: 'dashboard', component: DashboardComponent, canActivate: [ IsAuthenticatedAuthGuard, EncryptionEnabledAuthGuard] },
|
||||
{ path: 'source/:source_id', component: SourceDetailComponent, canActivate: [ IsAuthenticatedAuthGuard, EncryptionEnabledAuthGuard] },
|
||||
{ path: 'resource/:resource_id', component: ResourceDetailComponent, canActivate: [ IsAuthenticatedAuthGuard, EncryptionEnabledAuthGuard] },
|
||||
{ path: 'sources', component: MedicalSourcesComponent, canActivate: [ IsAuthenticatedAuthGuard, EncryptionEnabledAuthGuard] },
|
||||
{ path: 'sources/callback/:source_type', component: MedicalSourcesComponent, canActivate: [ IsAuthenticatedAuthGuard, EncryptionEnabledAuthGuard] },
|
||||
|
||||
{ path: 'account/security/manager', component: EncryptionManagerComponent, canActivate: [ IsAuthenticatedAuthGuard] },
|
||||
|
||||
|
||||
// { path: 'general-pages', loadChildren: () => import('./general-pages/general-pages.module').then(m => m.GeneralPagesModule) },
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
import { TestBed, async } from '@angular/core/testing';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { AppComponent } from './app.component';
|
||||
import {HttpClientTestingModule} from '@angular/common/http/testing';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
RouterTestingModule
|
||||
RouterTestingModule,
|
||||
HttpClientTestingModule
|
||||
],
|
||||
declarations: [
|
||||
AppComponent
|
||||
|
|
|
@ -18,13 +18,15 @@ import { AuthSignupComponent } from './pages/auth-signup/auth-signup.component';
|
|||
import { AuthSigninComponent } from './pages/auth-signin/auth-signin.component';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NgxDropzoneModule } from 'ngx-dropzone';
|
||||
import { CanActivateAuthGuard } from './services/can-activate.auth-guard';
|
||||
import { IsAuthenticatedAuthGuard } from './auth-guards/is-authenticated-auth-guard';
|
||||
import { EncryptionEnabledAuthGuard } from './auth-guards/encryption-enabled.auth-guard';
|
||||
import {FastenDbService} from './services/fasten-db.service';
|
||||
import {Router} from '@angular/router';
|
||||
import { SourceDetailComponent } from './pages/source-detail/source-detail.component';
|
||||
import { HighlightModule, HIGHLIGHT_OPTIONS } from 'ngx-highlightjs';
|
||||
import {AuthInterceptorService} from './services/auth-interceptor.service';
|
||||
import { MomentModule } from 'ngx-moment';
|
||||
import { EncryptionManagerComponent } from './pages/encryption-manager/encryption-manager.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
|
@ -37,6 +39,7 @@ import { MomentModule } from 'ngx-moment';
|
|||
AuthSignupComponent,
|
||||
AuthSigninComponent,
|
||||
SourceDetailComponent,
|
||||
EncryptionManagerComponent,
|
||||
],
|
||||
imports: [
|
||||
FormsModule,
|
||||
|
@ -58,7 +61,8 @@ import { MomentModule } from 'ngx-moment';
|
|||
multi: true,
|
||||
deps: [FastenDbService, Router]
|
||||
},
|
||||
CanActivateAuthGuard,
|
||||
IsAuthenticatedAuthGuard,
|
||||
EncryptionEnabledAuthGuard,
|
||||
{
|
||||
provide: HIGHLIGHT_OPTIONS,
|
||||
useValue: {
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import {CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree, Router} from '@angular/router';
|
||||
import {FastenDbService} from '../services/fasten-db.service';
|
||||
|
||||
@Injectable()
|
||||
export class EncryptionEnabledAuthGuard implements CanActivate {
|
||||
constructor(private fastenDbService: FastenDbService, private router: Router) {
|
||||
|
||||
}
|
||||
|
||||
async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise <boolean> {
|
||||
//check if the user has encryption data stored in this browser already
|
||||
if (!await this.fastenDbService.isCryptConfigAvailable()) {
|
||||
return await this.router.navigate(['/account/security/manager']);
|
||||
}
|
||||
// continue as normal
|
||||
return true
|
||||
}
|
||||
}
|
|
@ -1,9 +1,9 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import {CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree, Router} from '@angular/router';
|
||||
import { FastenDbService } from './fasten-db.service';
|
||||
import { FastenDbService } from '../services/fasten-db.service';
|
||||
|
||||
@Injectable()
|
||||
export class CanActivateAuthGuard implements CanActivate {
|
||||
export class IsAuthenticatedAuthGuard implements CanActivate {
|
||||
constructor(private fastenDbService: FastenDbService, private router: Router) {
|
||||
|
||||
}
|
||||
|
@ -11,7 +11,7 @@ export class CanActivateAuthGuard implements CanActivate {
|
|||
async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise <boolean> {
|
||||
//check if the user is authenticated, if not, redirect to login
|
||||
if (! await this.fastenDbService.IsAuthenticated()) {
|
||||
this.router.navigate(['/auth/signin']);
|
||||
return await this.router.navigate(['/auth/signin']);
|
||||
}
|
||||
// continue as normal
|
||||
return true
|
|
@ -61,7 +61,7 @@
|
|||
<div class="az-img-user">
|
||||
<img src="assets/logo/logo-text.png" alt="">
|
||||
</div><!-- az-img-user -->
|
||||
<h6>John Doe</h6>
|
||||
<h6>{{current_user}}</h6>
|
||||
<span>Adminstrator</span>
|
||||
</div><!-- az-header-profile -->
|
||||
|
||||
|
|
|
@ -7,10 +7,11 @@ import { Router } from '@angular/router';
|
|||
styleUrls: ['./header.component.scss']
|
||||
})
|
||||
export class HeaderComponent implements OnInit {
|
||||
|
||||
current_user: string
|
||||
constructor(private fastenDb: FastenDbService, private router: Router) { }
|
||||
|
||||
ngOnInit() {
|
||||
this.current_user = this.fastenDb.current_user
|
||||
}
|
||||
|
||||
closeMenu(e) {
|
||||
|
|
|
@ -2,8 +2,7 @@ import {Source} from '../../../lib/models/database/source';
|
|||
|
||||
export class SourceSyncMessage {
|
||||
source: Source
|
||||
userIdentifier: string
|
||||
encryptionKey?: string
|
||||
current_user: string
|
||||
|
||||
response?: any
|
||||
}
|
||||
|
|
|
@ -0,0 +1,151 @@
|
|||
<div class="az-content az-content-profile">
|
||||
<div class="container">
|
||||
<div class="row w-100">
|
||||
<div class="col-sm-12">
|
||||
<h2 class="az-content-title mg-t-20">Security Manager</h2>
|
||||
<p class="mb-5">
|
||||
Before you use Fasten you'll need to import or generate a new encryption key.
|
||||
<br/>
|
||||
<br/>
|
||||
Fasten uses <a href="https://en.wikipedia.org/wiki/Zero-knowledge_service" target="_blank">zero-knowledge encryption</a> to secure your medical data.
|
||||
This means that your medical records are encrypted on your device, before they are stored in the Fasten database.
|
||||
The encrypted data stored in the database is worthless without your encryption key.
|
||||
<br/>
|
||||
<br/>
|
||||
You must safely store your encryption key as you would a username & password,
|
||||
as it's the only thing that will allow you to access your records.</p>
|
||||
|
||||
<div *ngIf="cryptoPanel == CryptoPanelType.Generate" class="wizard clearfix">
|
||||
<div class="steps clearfix">
|
||||
<ul role="tablist">
|
||||
<li role="tab" class="first" [ngClass]="{'current': currentStep == 1, 'disabled': currentStep != 1}">
|
||||
<a aria-controls="wizard1-p-0">
|
||||
<span class="number">1</span>
|
||||
<span class="title">Download</span></a>
|
||||
</li>
|
||||
<li role="tab" class="last" [ngClass]="{'current': currentStep == 2, 'disabled': currentStep != 2}">
|
||||
<a aria-controls="wizard1-p-2">
|
||||
<span class="number">2</span>
|
||||
<span class="title">Validate</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="content clearfix">
|
||||
<ng-container [ngSwitch]="currentStep">
|
||||
<ng-container *ngSwitchCase="1">
|
||||
<h3 tabindex="-1" class="title current">Generate an encryption key</h3>
|
||||
<section role="tabpanel" aria-labelledby="wizard1-h-0" class="body current" aria-hidden="false">
|
||||
<p class="mg-b-0">
|
||||
Fasten has generated an encryption key for you. You can use this encryption key to decode your medical records on this browser, and other devices.
|
||||
<br/>
|
||||
|
||||
This is the only time the encryption key will be available to view, copy or download. We recommend downloading this key and storing the file in a secure location.
|
||||
<br/>
|
||||
You can reset your encryption key at any time, however any previously encrypted data will no longer be accessible.
|
||||
</p>
|
||||
|
||||
<pre><code [highlight]="currentCryptoConfig | json"></code></pre>
|
||||
|
||||
<div class="row row-xs wd-xl-80p">
|
||||
<div class="col-sm-6 col-md-3">
|
||||
<a [href]="generateCryptoConfigUrl" [download]="generateCryptoConfigFilename" class="btn btn-warning btn-rounded btn-block">Download Encryption Key</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="2">
|
||||
<h3 tabindex="-1" class="title current">Validate your encryption key</h3>
|
||||
<section role="tabpanel" aria-labelledby="wizard1-h-0" class="body current" aria-hidden="false">
|
||||
<p class="mg-b-10">
|
||||
Please select your encryption key (which you generated in the previous step) using the file input below.
|
||||
<br/>
|
||||
It'll be validated against your browser's encryption key to ensure fidelity.
|
||||
</p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-4">
|
||||
<div class="custom-file">
|
||||
<input type="file" class="custom-file-input" id="generateCustomFile" (change)="generateOpenFileHandler($event.target.files)" accept="application/json">
|
||||
<label class="custom-file-label" for="generateCustomFile">Choose file</label>
|
||||
<div *ngIf="generateCustomFileError" class="alert alert-danger">
|
||||
{{generateCustomFileError}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</ng-container>
|
||||
|
||||
</ng-container>
|
||||
</div>
|
||||
<div *ngIf="currentStep != lastStep" class="actions clearfix">
|
||||
<ul role="menu" aria-label="Pagination">
|
||||
<li class="disabled" aria-disabled="true"></li>
|
||||
<li aria-disabled="false"><button class="btn btn-az-primary btn-block" (click)="nextHandler()" role="menuitem">Next</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div *ngIf="cryptoPanel == CryptoPanelType.Import" class="wizard clearfix">
|
||||
<div class="steps clearfix">
|
||||
<ul role="tablist">
|
||||
<li role="tab" class="first" [ngClass]="{'current': currentStep == 1, 'disabled': currentStep != 1}">
|
||||
<a aria-controls="wizard1-p-0">
|
||||
<span class="number">1</span>
|
||||
<span class="title">Import</span></a>
|
||||
</li>
|
||||
<li role="tab" class="last" [ngClass]="{'current': currentStep == 2, 'disabled': currentStep != 2}">
|
||||
<a aria-controls="wizard1-p-2">
|
||||
<span class="number">2</span>
|
||||
<span class="title">Validate</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="content clearfix">
|
||||
<ng-container [ngSwitch]="currentStep">
|
||||
<ng-container *ngSwitchCase="1">
|
||||
<h3 tabindex="-1" class="title current">Import existing encryption key</h3>
|
||||
<section role="tabpanel" aria-labelledby="wizard1-h-0" class="body current" aria-hidden="false">
|
||||
<p class="mg-b-10">
|
||||
Fasten was unable to find your encryption key on this device, and has detected encrypted data in your database.
|
||||
<br/>
|
||||
You will need to provide your encryption key to access your health records.
|
||||
</p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-4">
|
||||
<div class="custom-file">
|
||||
<input type="file" class="custom-file-input" id="importCustomFile" (change)="importOpenFileHandler($event.target.files)" accept="application/json">
|
||||
<label class="custom-file-label" for="importCustomFile">Choose file</label>
|
||||
<div *ngIf="importCustomFileError" class="alert alert-danger">
|
||||
{{importCustomFileError}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="2">
|
||||
<h3 tabindex="-1" class="title current">Validate encryption key</h3>
|
||||
<section role="tabpanel" aria-labelledby="wizard1-h-0" class="body current" aria-hidden="false">
|
||||
<p class="mg-b-10">
|
||||
Thank you for providing your encryption key. Fasten will attempt to decrypt your secured records with this key.
|
||||
</p>
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
</section>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,26 @@
|
|||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { EncryptionManagerComponent } from './encryption-manager.component';
|
||||
import {HttpClientTestingModule} from '@angular/common/http/testing';
|
||||
|
||||
describe('EncryptionManagerComponent', () => {
|
||||
let component: EncryptionManagerComponent;
|
||||
let fixture: ComponentFixture<EncryptionManagerComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ EncryptionManagerComponent ],
|
||||
imports: [HttpClientTestingModule],
|
||||
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(EncryptionManagerComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,183 @@
|
|||
import { Component, OnInit } from '@angular/core';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {PouchdbCryptConfig, PouchdbCrypto} from '../../../lib/database/plugins/crypto';
|
||||
import {FastenDbService} from '../../services/fasten-db.service';
|
||||
import {DomSanitizer, SafeResourceUrl} from '@angular/platform-browser';
|
||||
import {Router} from '@angular/router';
|
||||
import {ToastService} from '../../services/toast.service';
|
||||
import {ToastNotification, ToastType} from '../../models/fasten/toast';
|
||||
|
||||
export enum CryptoPanelType {
|
||||
Loading,
|
||||
Import,
|
||||
Generate,
|
||||
}
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-encryption-manager',
|
||||
templateUrl: './encryption-manager.component.html',
|
||||
styleUrls: ['./encryption-manager.component.scss']
|
||||
})
|
||||
export class EncryptionManagerComponent implements OnInit {
|
||||
CryptoPanelType = CryptoPanelType
|
||||
cryptoPanel: CryptoPanelType = CryptoPanelType.Loading
|
||||
currentCryptoConfig: PouchdbCryptConfig = null
|
||||
|
||||
generateCryptoConfigUrl: SafeResourceUrl = ""
|
||||
generateCryptoConfigFilename: string = ""
|
||||
generateCustomFileError: string = ""
|
||||
|
||||
importCustomFileError: string = ""
|
||||
|
||||
currentStep: number
|
||||
lastStep: number
|
||||
|
||||
|
||||
constructor(private fastenDbService: FastenDbService, private sanitizer: DomSanitizer, private router: Router, private toastService: ToastService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
|
||||
this.fastenDbService.IsDatabasePopulated()
|
||||
.then((isPopulated) => {
|
||||
if(isPopulated){
|
||||
return this.showImportCryptoConfig()
|
||||
} else {
|
||||
return this.showGenerateCryptoConfig()
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
nextHandler() {
|
||||
this.currentStep += 1
|
||||
// if (!this.stepsService.isLastStep()) {
|
||||
// this.stepsService.moveToNextStep();
|
||||
// } else {
|
||||
// this.onSubmit();
|
||||
// }
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Generate Wizard Methods
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
async showGenerateCryptoConfig(): Promise<PouchdbCryptConfig> {
|
||||
this.cryptoPanel = CryptoPanelType.Generate
|
||||
this.currentStep = 1
|
||||
this.lastStep = 2
|
||||
if(!this.currentCryptoConfig){
|
||||
this.currentCryptoConfig = await PouchdbCrypto.CryptConfig(uuidv4(), this.fastenDbService.current_user)
|
||||
await PouchdbCrypto.StoreCryptConfig(this.currentCryptoConfig) //store in indexdb
|
||||
|
||||
|
||||
//generate URL for downloading.
|
||||
let currentCryptoConfigBlob = new Blob([JSON.stringify(this.currentCryptoConfig)], { type: 'application/json' });
|
||||
this.generateCryptoConfigUrl = this.sanitizer.bypassSecurityTrustResourceUrl(window.URL.createObjectURL(currentCryptoConfigBlob));
|
||||
this.generateCryptoConfigFilename = `fasten-${this.fastenDbService.current_user}.key.json`
|
||||
}
|
||||
|
||||
return this.currentCryptoConfig
|
||||
}
|
||||
|
||||
generateOpenFileHandler(fileList: FileList) {
|
||||
this.generateCustomFileError = ""
|
||||
let file = fileList[0];
|
||||
this.readFileContent(file)
|
||||
.then((content) => {
|
||||
let parsedCryptoConfig = JSON.parse(content) as PouchdbCryptConfig
|
||||
|
||||
//check if the parsed encryption key matches the currently set encryption key
|
||||
|
||||
if(parsedCryptoConfig.key == this.currentCryptoConfig.key &&
|
||||
parsedCryptoConfig.username == this.currentCryptoConfig.username &&
|
||||
parsedCryptoConfig.config == this.currentCryptoConfig.config){
|
||||
return true
|
||||
} else {
|
||||
//throw an error & notify user
|
||||
this.generateCustomFileError = "Crypto configuration file does not match"
|
||||
throw new Error(this.generateCustomFileError)
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
|
||||
const toastNotification = new ToastNotification()
|
||||
toastNotification.type = ToastType.Success
|
||||
toastNotification.message = "Successfully validated & stored encryption key."
|
||||
toastNotification.autohide = true
|
||||
this.toastService.show(toastNotification)
|
||||
|
||||
//redirect user to dashboard
|
||||
return this.router.navigate(['/dashboard']);
|
||||
})
|
||||
.catch(console.error)
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Import Wizard Methods
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
async showImportCryptoConfig(): Promise<any> {
|
||||
this.cryptoPanel = CryptoPanelType.Import
|
||||
this.currentStep = 1
|
||||
this.lastStep = 2
|
||||
}
|
||||
|
||||
importOpenFileHandler(fileList: FileList) {
|
||||
this.importCustomFileError = ""
|
||||
let file = fileList[0];
|
||||
this.readFileContent(file)
|
||||
.then((content) => {
|
||||
let cryptoConfig = JSON.parse(content) as PouchdbCryptConfig
|
||||
|
||||
if(cryptoConfig.key && cryptoConfig.config){
|
||||
return PouchdbCrypto.StoreCryptConfig(cryptoConfig)
|
||||
} else {
|
||||
//throw an error & notify user
|
||||
this.importCustomFileError = "Invalid crypto configuration file"
|
||||
throw new Error(this.importCustomFileError)
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
//go to step 2
|
||||
this.currentStep = 2
|
||||
//attempt to initialize pouchdb with specified crypto
|
||||
this.fastenDbService.ResetDB()
|
||||
return this.fastenDbService.GetSources()
|
||||
|
||||
})
|
||||
.then(() => {
|
||||
const toastNotification = new ToastNotification()
|
||||
toastNotification.type = ToastType.Success
|
||||
toastNotification.message = "Successfully validated & imported encryption key."
|
||||
toastNotification.autohide = true
|
||||
this.toastService.show(toastNotification)
|
||||
|
||||
return this.router.navigate(['/dashboard']);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
//an error occurred while importing credential
|
||||
const toastNotification = new ToastNotification()
|
||||
toastNotification.type = ToastType.Error
|
||||
toastNotification.message = "Provided encryption key does not match. Please try a different key"
|
||||
toastNotification.autohide = false
|
||||
this.toastService.show(toastNotification)
|
||||
this.currentStep = 1
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
private readFileContent(file: File): Promise<string>{
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
if (!file) {
|
||||
resolve('');
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const text = reader.result.toString();
|
||||
resolve(text);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -18,6 +18,7 @@ PouchDB.plugin(PouchUpsert);
|
|||
import * as PouchCrypto from 'crypto-pouch';
|
||||
PouchDB.plugin(PouchCrypto);
|
||||
import PouchAuth from 'pouchdb-authentication'
|
||||
import {PouchdbCrypto} from '../../lib/database/plugins/crypto';
|
||||
PouchDB.plugin(PouchAuth);
|
||||
|
||||
@Injectable({
|
||||
|
@ -25,11 +26,8 @@ PouchDB.plugin(PouchAuth);
|
|||
})
|
||||
export class FastenDbService extends PouchdbRepository {
|
||||
|
||||
|
||||
constructor(private _httpClient: HttpClient) {
|
||||
const userIdentifier = localStorage.getItem("current_user")
|
||||
super(userIdentifier, "my-secret-encryption-key");
|
||||
|
||||
super();
|
||||
}
|
||||
|
||||
|
||||
|
@ -46,6 +44,7 @@ export class FastenDbService extends PouchdbRepository {
|
|||
let remotePouchDb = new PouchDB(this.getRemoteUserDb(username), {skip_setup: true});
|
||||
return await remotePouchDb.logIn(username, pass)
|
||||
.then((loginResp)=>{
|
||||
this.current_user = loginResp.name
|
||||
return this.postLoginHook(loginResp.name, remotePouchDb)
|
||||
})
|
||||
.catch((err) => {
|
||||
|
@ -73,37 +72,68 @@ export class FastenDbService extends PouchdbRepository {
|
|||
await this.pouchDb.logOut()
|
||||
}
|
||||
await this.Close()
|
||||
localStorage.removeItem("current_user")
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to get PouchDB database using session information
|
||||
* @constructor
|
||||
*/
|
||||
public override async GetSessionDB(): Promise<PouchDB.Database> {
|
||||
if(this.pouchDb){
|
||||
console.log("Session DB already exists..")
|
||||
return this.pouchDb
|
||||
}
|
||||
|
||||
//Since we dont have a pre-configured pouchDB already, see if we have an active session to the remote database.
|
||||
let sessionDb = new PouchDB(this.getRemoteUserDb("placeholder"))
|
||||
const session = await sessionDb.getSession()
|
||||
console.log("Session found...", session)
|
||||
|
||||
const authUser = session?.userCtx?.name
|
||||
if(authUser){
|
||||
this.pouchDb = new PouchDB(this.getRemoteUserDb(authUser))
|
||||
this.current_user = authUser
|
||||
}
|
||||
return this.pouchDb
|
||||
}
|
||||
|
||||
//TODO: now that we've moved to remote-first database, we can refactor and simplify this function significantly.
|
||||
public async IsAuthenticated(): Promise<boolean> {
|
||||
if(!this.pouchDb){
|
||||
console.warn("IsAuthenticated? ====> logout, no local database present")
|
||||
//if no local database available, we're always "unauthenticated".
|
||||
return false
|
||||
}
|
||||
if(!localStorage.getItem("current_user")){
|
||||
console.warn("IsAuthenticated? ====> logout, no current_user set")
|
||||
return false
|
||||
}
|
||||
|
||||
try{
|
||||
//if we have a local database, lets see if we have an active session to the remote database.
|
||||
// const remotePouchDb = new PouchDB(this.getRemoteUserDb(localStorage.getItem("current_user")), {skip_setup: true});
|
||||
const session = await this.pouchDb.getSession()
|
||||
const authUser = session?.userCtx?.name
|
||||
const isAuth = !!authUser
|
||||
console.warn("IsAuthenticated? getSession() ====> ", isAuth)
|
||||
if(!isAuth){
|
||||
//lets see if we have an active session to the remote database.
|
||||
await this.GetSessionDB()
|
||||
if(!this.pouchDb){
|
||||
console.warn("could not determine database from session info, logging out")
|
||||
return false
|
||||
}
|
||||
//confirm that the logged in user matches the session user
|
||||
return authUser == localStorage.getItem("current_user")
|
||||
|
||||
let session = await this.pouchDb.getSession()
|
||||
let authUser = session?.userCtx?.name
|
||||
let isAuth = !!authUser
|
||||
console.warn("IsAuthenticated? getSession() ====> ", isAuth)
|
||||
return isAuth;
|
||||
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the crypto configuration for the authenticated user already available in the browser? Or do we need to import/generate new config.
|
||||
*/
|
||||
public async isCryptConfigAvailable(): Promise<boolean>{
|
||||
try {
|
||||
await this.GetSessionDB()
|
||||
let cryptConfig = await PouchdbCrypto.RetrieveCryptConfig(this.current_user)
|
||||
|
||||
return !!cryptConfig
|
||||
}catch(e){
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public Close(): Promise<void> {
|
||||
return super.Close()
|
||||
}
|
||||
|
@ -170,7 +200,6 @@ export class FastenDbService extends PouchdbRepository {
|
|||
* @constructor
|
||||
*/
|
||||
private async postLoginHook(userIdentifier: string, pouchDb: PouchDB.Database): Promise<void> {
|
||||
localStorage.setItem("current_user", userIdentifier)
|
||||
|
||||
await this.Close();
|
||||
this.pouchDb = pouchDb;
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { QueueService } from './queue.service';
|
||||
import {HttpClientTestingModule} from '@angular/common/http/testing';
|
||||
|
||||
describe('QueueService', () => {
|
||||
let service: QueueService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
});
|
||||
service = TestBed.inject(QueueService);
|
||||
});
|
||||
|
||||
|
|
|
@ -5,20 +5,20 @@ import {Source} from '../../lib/models/database/source';
|
|||
import {SourceSyncMessage} from '../models/queue/source-sync-message';
|
||||
import {ToastService} from '../services/toast.service';
|
||||
import {ToastNotification, ToastType} from '../models/fasten/toast';
|
||||
import {FastenDbService} from '../services/fasten-db.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class QueueService {
|
||||
|
||||
constructor(private toastService: ToastService) { }
|
||||
constructor(private toastService: ToastService, private fastenDbService: FastenDbService) { }
|
||||
|
||||
runSourceSyncWorker(source: Source):Observable<string> {
|
||||
if (typeof Worker !== 'undefined') {
|
||||
const sourceSync = new SourceSyncMessage()
|
||||
sourceSync.source = source
|
||||
sourceSync.userIdentifier = localStorage.getItem("current_user")
|
||||
sourceSync.encryptionKey = "my-secret-encryption-key"
|
||||
sourceSync.current_user = this.fastenDbService.current_user
|
||||
const input$: Observable<string> = of(JSON.stringify(sourceSync));
|
||||
return fromWorker<string, string>(() => new Worker(new URL('./source-sync.worker', import.meta.url), {type: 'module'}), input$)
|
||||
// .subscribe(message => {
|
||||
|
|
|
@ -4,7 +4,7 @@ import {DoWork, runWorker} from 'observable-webworker';
|
|||
import {Observable} from 'rxjs';
|
||||
import {mergeMap} from 'rxjs/operators';
|
||||
import {SourceSyncMessage} from '../models/queue/source-sync-message';
|
||||
import {NewRepositiory} from '../../lib/database/pouchdb_repository';
|
||||
import {NewPouchdbRepositoryWebWorker, PouchdbRepository} from '../../lib/database/pouchdb_repository';
|
||||
import {NewClient} from '../../lib/conduit/factory';
|
||||
import {Source} from '../../lib/models/database/source';
|
||||
|
||||
|
@ -18,7 +18,7 @@ export class SourceSyncWorker implements DoWork<string, string> {
|
|||
console.log(msg); // outputs 'Hello from main thread'
|
||||
const sourceSyncMessage = JSON.parse(msg) as SourceSyncMessage
|
||||
|
||||
const db = NewRepositiory(sourceSyncMessage.userIdentifier, sourceSyncMessage.encryptionKey)
|
||||
const db = NewPouchdbRepositoryWebWorker(sourceSyncMessage.current_user)
|
||||
const client = NewClient(sourceSyncMessage.source.source_type, new Source(sourceSyncMessage.source))
|
||||
//TODO: validate the FHIR version from the datasource matches the client
|
||||
// if the source token has been refreshed, we need to store it in the DB.
|
||||
|
|
|
@ -0,0 +1,307 @@
|
|||
/***** DEFAULT STYLE WIZARD *****/
|
||||
.wizard {
|
||||
border: 1px solid #e3e7ed;
|
||||
background-color: #fff; }
|
||||
.wizard > .steps {
|
||||
padding: 20px; }
|
||||
@media (min-width: 768px) {
|
||||
.wizard > .steps {
|
||||
padding: 25px; } }
|
||||
@media (min-width: 992px) {
|
||||
.wizard > .steps {
|
||||
padding: 30px; } }
|
||||
.wizard > .steps > ul {
|
||||
padding: 0;
|
||||
margin-bottom: 0;
|
||||
display: flex; }
|
||||
.wizard > .steps > ul li {
|
||||
float: none;
|
||||
display: block;
|
||||
width: auto; }
|
||||
.wizard > .steps > ul li .current-info {
|
||||
display: none; }
|
||||
.wizard > .steps > ul li .title {
|
||||
margin-left: 5px;
|
||||
white-space: nowrap;
|
||||
transition: all 0.2s ease-in-out; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wizard > .steps > ul li .title {
|
||||
transition: none; } }
|
||||
@media (min-width: 576px) {
|
||||
.wizard > .steps > ul li .title {
|
||||
display: none;
|
||||
margin-left: 10px; } }
|
||||
@media (min-width: 768px) {
|
||||
.wizard > .steps > ul li .title {
|
||||
display: inline-block; } }
|
||||
.wizard > .steps > ul li + li {
|
||||
margin-left: 5px; }
|
||||
@media (min-width: 576px) {
|
||||
.wizard > .steps > ul li + li {
|
||||
margin-left: 20px; } }
|
||||
@media (min-width: 992px) {
|
||||
.wizard > .steps > ul li + li {
|
||||
margin-left: 30px; } }
|
||||
.wizard > .steps a,
|
||||
.wizard > .steps a:hover,
|
||||
.wizard > .steps a:active {
|
||||
color: #1c273c;
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center; }
|
||||
@media (min-width: 1200px) {
|
||||
.wizard > .steps a,
|
||||
.wizard > .steps a:hover,
|
||||
.wizard > .steps a:active {
|
||||
justify-content: flex-start; } }
|
||||
.wizard > .steps a .number,
|
||||
.wizard > .steps a:hover .number,
|
||||
.wizard > .steps a:active .number {
|
||||
flex-shrink: 0;
|
||||
font-weight: 700;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
color: #fff;
|
||||
display: block;
|
||||
text-align: center;
|
||||
line-height: 2;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background-color: #b4bdce;
|
||||
border-radius: 100%;
|
||||
transition: all 0.2s ease-in-out; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wizard > .steps a .number,
|
||||
.wizard > .steps a:hover .number,
|
||||
.wizard > .steps a:active .number {
|
||||
transition: none; } }
|
||||
@media (min-width: 576px) {
|
||||
.wizard > .steps a .number,
|
||||
.wizard > .steps a:hover .number,
|
||||
.wizard > .steps a:active .number {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 2.1;
|
||||
width: 40px;
|
||||
height: 40px; } }
|
||||
.wizard > .steps .disabled {
|
||||
display: none; }
|
||||
@media (min-width: 576px) {
|
||||
.wizard > .steps .disabled {
|
||||
display: block; } }
|
||||
.wizard > .steps .disabled a,
|
||||
.wizard > .steps .disabled a:hover,
|
||||
.wizard > .steps .disabled a:active {
|
||||
color: #97a3b9; }
|
||||
.wizard > .steps .current a, .wizard > .steps .current a:hover, .wizard > .steps .current a:active {
|
||||
color: #5b47fb; }
|
||||
.wizard > .steps .current a .title, .wizard > .steps .current a:hover .title, .wizard > .steps .current a:active .title {
|
||||
display: inline-block; }
|
||||
.wizard > .steps .current a .number, .wizard > .steps .current a:hover .number, .wizard > .steps .current a:active .number {
|
||||
background-color: #5b47fb; }
|
||||
.wizard > .steps .done a, .wizard > .steps .done a:hover, .wizard > .steps .done a:active {
|
||||
color: #00cccc; }
|
||||
.wizard > .steps .done a .title, .wizard > .steps .done a:hover .title, .wizard > .steps .done a:active .title {
|
||||
display: none; }
|
||||
@media (min-width: 768px) {
|
||||
.wizard > .steps .done a .title, .wizard > .steps .done a:hover .title, .wizard > .steps .done a:active .title {
|
||||
display: inline-block; } }
|
||||
.wizard > .steps .done a .number, .wizard > .steps .done a:hover .number, .wizard > .steps .done a:active .number {
|
||||
background-color: #00cccc; }
|
||||
.wizard > .content {
|
||||
border-top: 1px solid #e3e7ed;
|
||||
border-bottom: 1px solid #e3e7ed;
|
||||
min-height: 150px;
|
||||
padding: 20px; }
|
||||
@media (min-width: 768px) {
|
||||
.wizard > .content {
|
||||
padding: 25px; } }
|
||||
@media (min-width: 992px) {
|
||||
.wizard > .content {
|
||||
padding: 30px; } }
|
||||
.wizard > .content > .title {
|
||||
font-size: 18px;
|
||||
color: #1c273c;
|
||||
font-weight: 700;
|
||||
margin-bottom: 5px;
|
||||
display: none; }
|
||||
.wizard > .content > .title.current {
|
||||
display: block; }
|
||||
.wizard > .content > .body {
|
||||
float: none;
|
||||
position: static;
|
||||
width: auto;
|
||||
height: auto; }
|
||||
.wizard > .content > .body input.parsley-error {
|
||||
border-color: #dc3545; }
|
||||
.wizard > .content > .body input.parsley-error + ul {
|
||||
list-style: none !important; }
|
||||
.wizard > .actions {
|
||||
padding: 20px; }
|
||||
@media (min-width: 768px) {
|
||||
.wizard > .actions {
|
||||
padding: 25px; } }
|
||||
@media (min-width: 992px) {
|
||||
.wizard > .actions {
|
||||
padding: 30px; } }
|
||||
.wizard > .actions > ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
justify-content: space-between; }
|
||||
.wizard > .actions > ul > li:last-child a {
|
||||
background-color: #00cccc; }
|
||||
.wizard > .actions a,
|
||||
.wizard > .actions a:hover,
|
||||
.wizard > .actions a:active {
|
||||
display: block;
|
||||
background-color: #5b47fb;
|
||||
padding: 9px 25px;
|
||||
line-height: 1.573;
|
||||
color: #fff; }
|
||||
.wizard > .actions .disabled a,
|
||||
.wizard > .actions .disabled a:hover,
|
||||
.wizard > .actions .disabled a:active {
|
||||
background-color: #97a3b9; }
|
||||
.wizard.vertical > .steps {
|
||||
padding: 20px; }
|
||||
@media (min-width: 576px) {
|
||||
.wizard.vertical > .steps {
|
||||
float: left;
|
||||
width: 20%; } }
|
||||
@media (min-width: 768px) {
|
||||
.wizard.vertical > .steps {
|
||||
width: 15%; } }
|
||||
@media (min-width: 992px) {
|
||||
.wizard.vertical > .steps {
|
||||
padding: 25px;
|
||||
width: 30%; } }
|
||||
@media (min-width: 576px) {
|
||||
.wizard.vertical > .steps ul {
|
||||
flex-direction: column; } }
|
||||
.wizard.vertical > .steps ul li + li {
|
||||
margin-left: 10px; }
|
||||
@media (min-width: 576px) {
|
||||
.wizard.vertical > .steps ul li + li {
|
||||
margin-top: 10px;
|
||||
margin-left: 0; } }
|
||||
@media (min-width: 768px) {
|
||||
.wizard.vertical > .steps ul li + li {
|
||||
margin-top: 20px; } }
|
||||
.wizard.vertical > .steps ul li .title {
|
||||
display: none; }
|
||||
@media (min-width: 992px) {
|
||||
.wizard.vertical > .steps ul li .title {
|
||||
display: block; } }
|
||||
@media (min-width: 992px) {
|
||||
.wizard.vertical > .steps a {
|
||||
justify-content: flex-start; } }
|
||||
.wizard.vertical > .steps .current a .title {
|
||||
display: inline-block; }
|
||||
@media (min-width: 576px) {
|
||||
.wizard.vertical > .steps .current a .title {
|
||||
display: none; } }
|
||||
@media (min-width: 992px) {
|
||||
.wizard.vertical > .steps .current a .title {
|
||||
display: inline-block; } }
|
||||
.wizard.vertical > .content {
|
||||
margin: 0;
|
||||
padding: 20px; }
|
||||
@media (min-width: 576px) {
|
||||
.wizard.vertical > .content {
|
||||
border-top-width: 0;
|
||||
border-bottom-width: 0;
|
||||
width: 80%;
|
||||
float: right;
|
||||
border-left: 1px solid #cdd4e0; } }
|
||||
@media (min-width: 768px) {
|
||||
.wizard.vertical > .content {
|
||||
width: 85%; } }
|
||||
@media (min-width: 992px) {
|
||||
.wizard.vertical > .content {
|
||||
width: 70%;
|
||||
padding: 25px; } }
|
||||
.wizard.vertical > .actions {
|
||||
padding: 20px; }
|
||||
@media (min-width: 576px) {
|
||||
.wizard.vertical > .actions {
|
||||
width: 80%;
|
||||
float: right;
|
||||
border-left: 1px solid #cdd4e0; } }
|
||||
@media (min-width: 768px) {
|
||||
.wizard.vertical > .actions {
|
||||
width: 85%; } }
|
||||
@media (min-width: 992px) {
|
||||
.wizard.vertical > .actions {
|
||||
width: 70%;
|
||||
padding: 25px; } }
|
||||
.wizard.vertical > .actions ul {
|
||||
float: none;
|
||||
margin: 0;
|
||||
padding: 0; }
|
||||
|
||||
/****** EQUAL COLUMN WIDTH STEP INDICATOR *****/
|
||||
.step-equal-width > .steps > ul {
|
||||
display: flex; }
|
||||
.step-equal-width > .steps > ul > li {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
float: none; }
|
||||
|
||||
/****** EQUAL COLUMN WIDTH STEP INDICATOR *****/
|
||||
.step-equal-width > .steps > ul {
|
||||
display: flex; }
|
||||
.step-equal-width > .steps > ul > li {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
float: none; }
|
||||
|
||||
/***** CUSTOM STYLES *****/
|
||||
.wizard-style-1 > .steps > ul a, .wizard-style-1 > .steps > ul a:hover, .wizard-style-1 > .steps > ul a:active {
|
||||
padding: 0;
|
||||
height: 50px; }
|
||||
.wizard-style-1 > .steps > ul a .number, .wizard-style-1 > .steps > ul a:hover .number, .wizard-style-1 > .steps > ul a:active .number {
|
||||
width: 50px;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #7987a1;
|
||||
background-color: #cdd4e0;
|
||||
border-radius: 0; }
|
||||
.wizard-style-1 > .steps > ul a .title, .wizard-style-1 > .steps > ul a:hover .title, .wizard-style-1 > .steps > ul a:active .title {
|
||||
margin-right: 20px;
|
||||
margin-left: 20px; }
|
||||
|
||||
.wizard-style-1 > .steps > ul .current a .number, .wizard-style-1 > .steps > ul .current a:hover .number, .wizard-style-1 > .steps > ul .current a:active .number {
|
||||
background-color: #452efa;
|
||||
color: #fff; }
|
||||
|
||||
.wizard-style-1 > .steps > ul .done a .number, .wizard-style-1 > .steps > ul .done a:hover .number, .wizard-style-1 > .steps > ul .done a:active .number {
|
||||
background-color: #643ab0;
|
||||
color: #fff; }
|
||||
|
||||
.wizard-style-2 > .steps > ul a, .wizard-style-2 > .steps > ul a:hover, .wizard-style-2 > .steps > ul a:active {
|
||||
padding: 0;
|
||||
height: 50px;
|
||||
border-radius: 50px; }
|
||||
.wizard-style-2 > .steps > ul a .number, .wizard-style-2 > .steps > ul a:hover .number, .wizard-style-2 > .steps > ul a:active .number {
|
||||
width: 50px;
|
||||
height: 100%;
|
||||
border: 2px solid #e3e7ed;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #7987a1;
|
||||
background-color: #fff; }
|
||||
.wizard-style-2 > .steps > ul a .title, .wizard-style-2 > .steps > ul a:hover .title, .wizard-style-2 > .steps > ul a:active .title {
|
||||
margin-right: 20px; }
|
||||
|
||||
.wizard-style-2 > .steps > ul .current a .number, .wizard-style-2 > .steps > ul .current a:hover .number, .wizard-style-2 > .steps > ul .current a:active .number {
|
||||
border-color: #5b47fb;
|
||||
color: #5b47fb; }
|
||||
|
||||
.wizard-style-2 > .steps > ul .done a .number, .wizard-style-2 > .steps > ul .done a:hover .number, .wizard-style-2 > .steps > ul .done a:active .number {
|
||||
border-color: #6f42c1;
|
||||
color: #6f42c1; }
|
|
@ -2,7 +2,7 @@ import {FHIR401Client} from './fhir401_r4_client';
|
|||
import {Source} from '../../../models/database/source';
|
||||
import {IResourceBundleRaw} from '../../interface';
|
||||
import {ResourceFhir} from '../../../models/database/resource_fhir';
|
||||
import {NewRepositiory} from '../../../database/pouchdb_repository';
|
||||
import {NewPouchdbRepositoryWebWorker} from '../../../database/pouchdb_repository';
|
||||
import {Base64} from '../../../utils/base64';
|
||||
import * as PouchDB from 'pouchdb/dist/pouchdb';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
@ -10,6 +10,7 @@ import { v4 as uuidv4 } from 'uuid';
|
|||
// @ts-ignore
|
||||
import * as FHIR401Client_ProcessBundle from './fixtures/FHIR401Client_ProcessBundle.json';
|
||||
import {IDatabaseRepository} from '../../../database/interface';
|
||||
import {PouchdbCrypto} from '../../../database/plugins/crypto';
|
||||
|
||||
|
||||
class TestClient extends FHIR401Client {
|
||||
|
@ -66,7 +67,10 @@ describe('FHIR401Client', () => {
|
|||
let repository: IDatabaseRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
repository = NewRepositiory(null, null, new PouchDB("FHIR401Client-"+ uuidv4()));
|
||||
let current_user = uuidv4()
|
||||
let cryptoConfig = await PouchdbCrypto.CryptConfig(current_user, current_user)
|
||||
await PouchdbCrypto.StoreCryptConfig(cryptoConfig)
|
||||
repository = NewPouchdbRepositoryWebWorker(current_user, new PouchDB("FHIR401Client-"+ current_user));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
|
@ -22,7 +22,7 @@ export interface IDatabasePaginatedResponse {
|
|||
rows: any[]
|
||||
}
|
||||
export interface IDatabaseRepository {
|
||||
GetDB(): any
|
||||
GetDB(skipEncryption?: boolean): any
|
||||
Close(): void
|
||||
|
||||
// CreateUser(*models.User) error
|
||||
|
@ -34,7 +34,7 @@ export interface IDatabaseRepository {
|
|||
DeleteSource(source_id: string): Promise<boolean>
|
||||
GetSourceSummary(source_id: string): Promise<SourceSummary>
|
||||
GetSources(): Promise<IDatabasePaginatedResponse>
|
||||
|
||||
IsDatabasePopulated(): Promise<boolean>
|
||||
|
||||
// UpsertResource(context.Context, *models.ResourceFhir) error
|
||||
// GetResourceBySourceType(context.Context, string, string) (*models.ResourceFhir, error)
|
||||
|
|
|
@ -0,0 +1,151 @@
|
|||
// This is a Typescript module that recreates the functionality defined in https://github.com/calvinmetcalf/crypto-pouch/blob/master/index.js
|
||||
// This file only exists because the PouchDB crypto plugin must work in both the browser and web-worker environment (where `window` is
|
||||
// undefined and causes errors).
|
||||
// Also, crypto-pouch does not support storing encrypted data in the remote database by default, which I'm attempting to do by commenting out the
|
||||
// NO_COUCH error.
|
||||
//
|
||||
// We've attempted to use the Typescript Module Plugin/Augmentation pattern to modify the global `pouchdb` object, however that
|
||||
// failed for a variety of reasons, so instead we're using a PouchdbCrypto class with static methods to re-implement the crypto logic
|
||||
//
|
||||
//
|
||||
// See:
|
||||
// - https://github.com/calvinmetcalf/crypto-pouch/blob/master/index.js
|
||||
// - https://www.typescriptlang.org/docs/handbook/declaration-files/templates/module-plugin-d-ts.html
|
||||
// - https://www.typescriptlang.org/docs/handbook/declaration-merging.html
|
||||
// - https://www.typescriptlang.org/docs/handbook/declaration-files/templates/global-plugin-d-ts.html
|
||||
// - https://www.typescriptlang.org/docs/handbook/declaration-files/templates/global-modifying-module-d-ts.html
|
||||
// - https://stackoverflow.com/questions/35074713/extending-typescript-global-object-in-node-js
|
||||
// - https://github.com/Microsoft/TypeScript/issues/15818
|
||||
|
||||
import * as Crypt from 'garbados-crypt';
|
||||
import { openDB, deleteDB, wrap, unwrap } from 'idb';
|
||||
|
||||
// const Crypt = require()
|
||||
|
||||
const LOCAL_ID = '_local/crypto'
|
||||
const IGNORE = ['_id', '_rev', '_deleted', '_conflicts']
|
||||
|
||||
// const NO_COUCH = 'crypto-pouch does not work with pouchdb\'s http adapter. Use a local adapter instead.'
|
||||
|
||||
export class PouchdbCryptoOptions {
|
||||
ignore?: string[]
|
||||
}
|
||||
|
||||
export class PouchdbCryptConfig {
|
||||
username: string
|
||||
key: string
|
||||
config: string
|
||||
}
|
||||
|
||||
|
||||
export class PouchdbCrypto {
|
||||
private static async localIdb(){
|
||||
const dbPromise = openDB('crypto-store', 1, {
|
||||
upgrade(db) {
|
||||
db.createObjectStore('crypto');
|
||||
},
|
||||
});
|
||||
|
||||
return await dbPromise
|
||||
}
|
||||
|
||||
|
||||
public static async CryptConfig(key: string, username): Promise<PouchdbCryptConfig>{
|
||||
const _crypt = new Crypt(key)
|
||||
let exportString = await _crypt.export()
|
||||
return {username: username, key: key, config: exportString}
|
||||
}
|
||||
|
||||
public static async StoreCryptConfig(cryptConfig: PouchdbCryptConfig) {
|
||||
const localDb = await PouchdbCrypto.localIdb()
|
||||
await localDb.put('crypto', JSON.stringify(cryptConfig), `encryption_data_${cryptConfig.username}`)
|
||||
}
|
||||
|
||||
public static async RetrieveCryptConfig(currentUser: string): Promise<PouchdbCryptConfig>{
|
||||
const localDb = await PouchdbCrypto.localIdb()
|
||||
let cryptConfigStr = await localDb.get('crypto',`encryption_data_${currentUser}`)
|
||||
if(!cryptConfigStr){
|
||||
throw new Error("crypto configuration not set")
|
||||
}
|
||||
return JSON.parse(cryptConfigStr) as PouchdbCryptConfig
|
||||
}
|
||||
|
||||
|
||||
public static async crypto(db, cryptConfig: PouchdbCryptConfig, options: PouchdbCryptoOptions = {}) {
|
||||
// if (db.adapter === 'http') {
|
||||
// throw new Error(NO_COUCH)
|
||||
// }
|
||||
// if (typeof password === 'object') {
|
||||
// // handle `db.crypto({ password, ...options })`
|
||||
// options = password
|
||||
// password = password.password
|
||||
// delete options.password
|
||||
// }
|
||||
// setup ignore list
|
||||
db._ignore = IGNORE.concat(options.ignore || [])
|
||||
|
||||
if(!cryptConfig || !cryptConfig.key || !cryptConfig.config){
|
||||
throw new Error("crypto configuration file is required")
|
||||
}
|
||||
|
||||
// setup crypto helper
|
||||
const trySetup = async () => {
|
||||
// try saving credentials to a local doc
|
||||
try {
|
||||
// // first we try to get saved creds from the local doc
|
||||
// const localDb = await PouchdbCrypto.localIdb()
|
||||
// let exportString = await localDb.get('crypto',`encryption_data_${db.name}`)
|
||||
// if(!exportString){
|
||||
// // no existing encryption key found
|
||||
//
|
||||
// // do first-time setup
|
||||
// db._crypt = new Crypt(password)
|
||||
// let exportString = await db._crypt.export()
|
||||
// await localDb.put('crypto', exportString, `encryption_data_${db.name}`)
|
||||
// } else {
|
||||
//
|
||||
// }
|
||||
|
||||
db._crypt = await Crypt.import(cryptConfig.key, cryptConfig.config)
|
||||
|
||||
} catch (err) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
await trySetup()
|
||||
// instrument document transforms
|
||||
db.transform({
|
||||
incoming: async (doc) => {
|
||||
// if no crypt, ex: after .removeCrypto(), just return the doc
|
||||
if (!db._crypt) {
|
||||
return doc
|
||||
}
|
||||
if (doc._attachments && !db._ignore.includes('_attachments')) {
|
||||
throw new Error('Attachments cannot be encrypted. Use {ignore: "_attachments"} option')
|
||||
}
|
||||
let encrypted: any = {}
|
||||
for (let key of db._ignore) {
|
||||
// attach ignored fields to encrypted doc
|
||||
if (key in doc) encrypted[key] = doc[key]
|
||||
}
|
||||
encrypted.payload = await db._crypt.encrypt(JSON.stringify(doc))
|
||||
return encrypted
|
||||
},
|
||||
outgoing: async (doc) => {
|
||||
// if no crypt, ex: after .removeCrypto(), just return the doc
|
||||
if (!db._crypt) { return doc }
|
||||
let decryptedString = await db._crypt.decrypt(doc.payload)
|
||||
let decrypted = JSON.parse(decryptedString)
|
||||
for (let key of db._ignore) {
|
||||
// patch decrypted doc with ignored fields
|
||||
if (key in doc) decrypted[key] = doc[key]
|
||||
}
|
||||
return decrypted
|
||||
}
|
||||
})
|
||||
return db
|
||||
}
|
||||
public static removeCrypto(db) {
|
||||
delete db._crypt
|
||||
}
|
||||
}
|
|
@ -1,3 +1,18 @@
|
|||
// This is a Typescript module that recreates the functionality defined in https://github.com/pouchdb/upsert/blob/master/index.js
|
||||
// This file only exists because the PouchDB upsert plugin must work in both the browser and web-worker environment (where `window` is
|
||||
// undefined and causes errors).
|
||||
//
|
||||
// We've attempted to use the Typescript Module Plugin/Augmentation pattern to modify the global `pouchdb` object, however that
|
||||
// failed for a variety of reasons, so instead we're using a PouchdbUpsert class with static methods to re-implement the upsert logic
|
||||
//
|
||||
// See:
|
||||
// - https://github.com/pouchdb/upsert/blob/master/index.js
|
||||
// - https://www.typescriptlang.org/docs/handbook/declaration-files/templates/module-plugin-d-ts.html
|
||||
// - https://www.typescriptlang.org/docs/handbook/declaration-merging.html
|
||||
// - https://www.typescriptlang.org/docs/handbook/declaration-files/templates/global-plugin-d-ts.html
|
||||
// - https://www.typescriptlang.org/docs/handbook/declaration-files/templates/global-modifying-module-d-ts.html
|
||||
// - https://stackoverflow.com/questions/35074713/extending-typescript-global-object-in-node-js
|
||||
// - https://github.com/Microsoft/TypeScript/issues/15818
|
||||
export class PouchdbUpsert {
|
||||
public static upsert(db, docId, diffFun, cb?) {
|
||||
var promise = PouchdbUpsert.upsertInner(db, docId, diffFun);
|
||||
|
|
|
@ -1,16 +1,20 @@
|
|||
import {IDatabaseRepository} from './interface';
|
||||
import {NewRepositiory} from './pouchdb_repository';
|
||||
import {NewPouchdbRepositoryWebWorker} from './pouchdb_repository';
|
||||
import {SourceType} from '../models/database/source_types';
|
||||
import {Source} from '../models/database/source';
|
||||
import {DocType} from './constants';
|
||||
import * as PouchDB from 'pouchdb/dist/pouchdb';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {PouchdbCrypto} from './plugins/crypto';
|
||||
|
||||
describe('PouchdbRepository', () => {
|
||||
let repository: IDatabaseRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
repository = NewRepositiory(null, null, new PouchDB("PouchdbRepository" + uuidv4()));
|
||||
let current_user = uuidv4()
|
||||
let cryptoConfig = await PouchdbCrypto.CryptConfig(current_user, current_user)
|
||||
await PouchdbCrypto.StoreCryptConfig(cryptoConfig)
|
||||
repository = NewPouchdbRepositoryWebWorker(current_user, new PouchDB("PouchdbRepository" + current_user));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
|
@ -7,12 +7,17 @@ import {Base64} from '../utils/base64';
|
|||
|
||||
// PouchDB & plugins
|
||||
import * as PouchDB from 'pouchdb/dist/pouchdb';
|
||||
import * as PouchCrypto from 'crypto-pouch';
|
||||
|
||||
// import * as PouchCrypto from 'crypto-pouch';
|
||||
// PouchDB.plugin(PouchCrypto);
|
||||
|
||||
import * as PouchTransform from 'transform-pouch';
|
||||
PouchDB.plugin(PouchTransform);
|
||||
|
||||
import {PouchdbUpsert} from './plugins/upsert';
|
||||
import {UpsertSummary} from '../models/fasten/upsert-summary';
|
||||
PouchDB.plugin(PouchCrypto);
|
||||
|
||||
|
||||
import {PouchdbCryptConfig, PouchdbCrypto, PouchdbCryptoOptions} from './plugins/crypto';
|
||||
|
||||
// !!!!!!!!!!!!!!!!WARNING!!!!!!!!!!!!!!!!!!!!!
|
||||
// most pouchdb plugins seem to fail when used in a webworker.
|
||||
|
@ -43,16 +48,22 @@ PouchDB.plugin(PouchCrypto);
|
|||
* Eventually this method should dyanmically dtermine the version of the repo to return from the env.
|
||||
* @constructor
|
||||
*/
|
||||
export function NewRepositiory(userIdentifier?: string, encryptionKey?: string, localPouchDb?: PouchDB.Database): IDatabaseRepository {
|
||||
return new PouchdbRepository(userIdentifier, encryptionKey, localPouchDb)
|
||||
}
|
||||
|
||||
export function NewPouchdbRepositoryWebWorker(current_user: string, localPouchDb?: PouchDB.Database): PouchdbRepository {
|
||||
let pouchdbRepository = new PouchdbRepository(localPouchDb)
|
||||
pouchdbRepository.current_user = current_user
|
||||
return pouchdbRepository
|
||||
}
|
||||
export class PouchdbRepository implements IDatabaseRepository {
|
||||
|
||||
// replicationHandler: any
|
||||
remotePouchEndpoint: string // "http://localhost:5984"
|
||||
encryptionKey: string
|
||||
pouchDb: PouchDB.Database
|
||||
current_user: string
|
||||
|
||||
//encryption configuration
|
||||
cryptConfig: PouchdbCryptConfig = null
|
||||
encryptionInitComplete: boolean = false
|
||||
|
||||
/**
|
||||
* This class can be initialized in 2 states
|
||||
|
@ -61,17 +72,13 @@ export class PouchdbRepository implements IDatabaseRepository {
|
|||
* @param userIdentifier
|
||||
* @param encryptionKey
|
||||
*/
|
||||
constructor(userIdentifier?: string, encryptionKey?: string, localPouchDb?: PouchDB.Database) {
|
||||
constructor(localPouchDb?: PouchDB.Database) {
|
||||
this.remotePouchEndpoint = `${globalThis.location.protocol}//${globalThis.location.host}${this.getBasePath()}/database`
|
||||
|
||||
//setup PouchDB Plugins
|
||||
//https://pouchdb.com/guides/mango-queries.html
|
||||
this.pouchDb = null
|
||||
if(userIdentifier){
|
||||
this.pouchDb = new PouchDB(this.getRemoteUserDb(userIdentifier));
|
||||
this.encryptionKey = encryptionKey
|
||||
// this.enableSync(userIdentifier)
|
||||
}
|
||||
|
||||
if(localPouchDb){
|
||||
console.warn("using local pouchdb, this should only be used for testing")
|
||||
this.pouchDb = localPouchDb
|
||||
|
@ -203,6 +210,30 @@ export class PouchdbRepository implements IDatabaseRepository {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* given an raw connection to a database, determine how many records/resources are stored within
|
||||
* @constructor
|
||||
*/
|
||||
public async IsDatabasePopulated(): Promise<boolean> {
|
||||
let resourceFhirCount = await this.findDocumentByPrefix(DocType.ResourceFhir, false, true)
|
||||
.then((resp) => {
|
||||
console.log("RESPONSE COUNT INFO", resp)
|
||||
return resp.rows.length
|
||||
})
|
||||
|
||||
if(resourceFhirCount > 0) {return true}
|
||||
|
||||
|
||||
let sourceCount = await this.findDocumentByPrefix(DocType.Source, false, true)
|
||||
.then((resp) => {
|
||||
console.log("SOURCE COUNT INFO", resp)
|
||||
|
||||
return resp.rows.length
|
||||
})
|
||||
if(sourceCount > 0) {return true}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
|
@ -210,25 +241,48 @@ export class PouchdbRepository implements IDatabaseRepository {
|
|||
// All functions below here will return the raw PouchDB responses, and may need to be wrapped in
|
||||
// new ResourceFhir(result.doc)
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
public ResetDB(){
|
||||
this.pouchDb = null
|
||||
this.encryptionInitComplete = false
|
||||
}
|
||||
// Get the active PouchDB instance. Throws an error if no PouchDB instance is
|
||||
// available (ie, user has not yet been configured with call to .configureForUser()).
|
||||
public async GetDB(): Promise<PouchDB.Database> {
|
||||
public async GetDB(skipEncryption: boolean = false): Promise<PouchDB.Database> {
|
||||
await this.GetSessionDB()
|
||||
|
||||
if(!this.pouchDb) {
|
||||
throw(new Error( "Database is not available - please configure an instance." ));
|
||||
}
|
||||
// if(this.encryptionKey){
|
||||
// return this.pouchDb.crypto(this.encryptionKey, {ignore:[
|
||||
// 'doc_type',
|
||||
// 'source_id',
|
||||
// 'source_resource_type',
|
||||
// 'source_resource_id',
|
||||
// ]}).then(() => {
|
||||
// return this.pouchDb
|
||||
// })
|
||||
// } else {
|
||||
|
||||
if(skipEncryption){
|
||||
//this allows the database to be queried, even when encryption has not been configured correctly
|
||||
//this will only be used to take a count of documents in the database, so we can prompt the user for a encryption key, or generate a new one (for an empty db)
|
||||
return this.pouchDb
|
||||
}
|
||||
|
||||
|
||||
//try to determine the crypto configuration using the currently logged in user.
|
||||
this.cryptConfig = await PouchdbCrypto.RetrieveCryptConfig(this.current_user)
|
||||
|
||||
if(!this.cryptConfig){
|
||||
throw new Error("crypto configuration not set.")
|
||||
}
|
||||
|
||||
if(!this.encryptionInitComplete){
|
||||
return PouchdbCrypto.crypto(this.pouchDb, this.cryptConfig, {ignore:[
|
||||
'doc_type',
|
||||
'source_id',
|
||||
'source_resource_type',
|
||||
'source_resource_id',
|
||||
]})
|
||||
.then((encryptedPouchDb) => {
|
||||
this.pouchDb = encryptedPouchDb
|
||||
this.encryptionInitComplete = true
|
||||
return this.pouchDb
|
||||
})
|
||||
} else {
|
||||
return this.pouchDb;
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
@ -324,8 +378,8 @@ export class PouchdbRepository implements IDatabaseRepository {
|
|||
protected findDocumentByDocType(docType: DocType, includeDocs: boolean = true): Promise<IDatabasePaginatedResponse> {
|
||||
return this.findDocumentByPrefix(docType, includeDocs)
|
||||
}
|
||||
protected findDocumentByPrefix(prefix: string, includeDocs: boolean = true): Promise<IDatabasePaginatedResponse> {
|
||||
return this.GetDB()
|
||||
protected findDocumentByPrefix(prefix: string, includeDocs: boolean = true, skipEncryption: boolean = false): Promise<IDatabasePaginatedResponse> {
|
||||
return this.GetDB(skipEncryption)
|
||||
.then((db) => {
|
||||
return db.allDocs({
|
||||
include_docs: includeDocs,
|
||||
|
@ -348,6 +402,24 @@ export class PouchdbRepository implements IDatabaseRepository {
|
|||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
// Sync private/protected methods
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
/**
|
||||
* Try to get PouchDB database using session information
|
||||
* This method is overridden in PouchDB Service, as session information is inaccessible in web-worker
|
||||
* @constructor
|
||||
*/
|
||||
public async GetSessionDB(): Promise<PouchDB.Database> {
|
||||
if(this.pouchDb){
|
||||
console.log("Session DB already exists..")
|
||||
return this.pouchDb
|
||||
}
|
||||
|
||||
if(!this.current_user){
|
||||
throw new Error("current user is required when initializing pouchdb within web-worker")
|
||||
}
|
||||
this.pouchDb = new PouchDB(this.getRemoteUserDb(this.current_user))
|
||||
return this.pouchDb
|
||||
}
|
||||
|
||||
protected getRemoteUserDb(username: string){
|
||||
return `${this.remotePouchEndpoint}/userdb-${this.toHex(username)}`
|
||||
}
|
||||
|
|
|
@ -184,6 +184,7 @@
|
|||
@import "./assets/scss/custom/list";
|
||||
@import "./assets/scss/custom/nav";
|
||||
@import "./assets/scss/custom/modal";
|
||||
@import "./assets/scss/custom/wizard";
|
||||
|
||||
/* ############### CUSTOM VENDOR STYLES ############### */
|
||||
@import "./assets/scss/lib/select2";
|
||||
|
|
|
@ -4579,6 +4579,11 @@ icss-utils@^5.0.0, icss-utils@^5.1.0:
|
|||
resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae"
|
||||
integrity sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==
|
||||
|
||||
idb@^7.1.0:
|
||||
version "7.1.0"
|
||||
resolved "https://registry.yarnpkg.com/idb/-/idb-7.1.0.tgz#2cc886be57738419e57f9aab58f647e5e2160270"
|
||||
integrity sha512-Wsk07aAxDsntgYJY4h0knZJuTxM73eQ4reRAO+Z1liOh8eMCJ/MoDS8fCui1vGT9mnjtl1sOu3I2i/W1swPYZg==
|
||||
|
||||
ieee754@^1.1.13:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
|
||||
|
|
Loading…
Reference in New Issue