2
0
Эх сурвалжийг харах

восстановление доступа

κρμγ 4 жил өмнө
parent
commit
75bf7c341e

+ 3 - 1
src/app/package.json

@@ -21,6 +21,8 @@
     "@angular/router": "~10.0.2",
     "@auth0/angular-jwt": "^4.0.0",
     "@stomp/ng2-stompjs": "^7.2.0",
+    "angular2-text-mask": "^9.0.0",
+    "jspdf": "^1.5.3",
     "moment": "^2.25.3",
     "ngx-init": "^0.1.1",
     "rxjs": "~6.6.0",
@@ -39,4 +41,4 @@
     "ts-node": "~8.3.0",
     "typescript": "~3.9.6"
   }
-}
+}

+ 16 - 6
src/app/src/app/app.login.component.html

@@ -1,17 +1,27 @@
 <div fxLayout="column" fxLayoutAlign="center center">
-  <mat-card style="width: 30em">
-    <p>Всё просто, для регистрации введите проверочную строку <br/><strong>{{(qr | async)?.key}}</strong><br/> или отсканируйте qr-код с помощью приложения Authenticator*, затем введите шестизначный код из приложения и можно начинать.</p>
+  <mat-card style="width: 32em">
+    <p>Всё просто, для регистрации введите проверочную строку <br/><strong>{{(qr | async)?.key}}</strong><br/> или отсканируйте qr-код с помощью приложения Authenticator<sup>*</sup>, затем введите шестизначный код из приложения и можно начинать.</p>
     <p>Или, если вы уже зарегистрированы, просто введите шестизначный код в поле ниже.</p>
     <div fxLayout="row" fxLayoutAlign="center center"><img alt="your qr-code here" height="320" src="{{(qr | async)?.img}}" width="320"></div>
-    <p>Введите проверочный код из приложения Authenticator*</p>
+    <p>Введите проверочный код из приложения Authenticator<sup>*</sup></p>
     <div fxLayout="row" fxLayoutAlign="center center">
       <mat-form-field>
         <mat-label>Проверочный код</mat-label>
+        <mat-icon *ngIf="_.isNull(valid)" matSuffix>qr_code</mat-icon>
+        <mat-icon *ngIf="valid === 1" matSuffix>done</mat-icon>
+        <mat-icon *ngIf="valid === 0" matSuffix>not_interested</mat-icon>
         <input (ngModelChange)="checkCode($event)" [ngModel]="code" matInput maxlength="6" minlength="6" pattern="^[0-9]+$" placeholder="123456" type="text"/>
       </mat-form-field>
-      <mat-icon *ngIf="valid === 1">done</mat-icon>
-      <mat-icon *ngIf="valid === 0">not_interested</mat-icon>
     </div>
-    <p>*приложение Authenticator может быть заменено аналогичным приложением с функциями TOTP</p>
+    <p *ngIf="valid === 0">Вход с нового устройства или утрачен ключ Authenticator<sup>*</sup>? Восстановите доступ с помощью кодов восстановления.</p>
+    <p style="font-size: small; color: gray"><sup>*</sup>приложение Authenticator может быть заменено аналогичным приложением с функциями TOTP</p>
+    <mat-card-actions fxLayout="row" fxLayoutAlign="center center">
+      <button (click)="ngOnInit()" mat-button>
+        <mat-icon>refresh</mat-icon>&nbsp;ОБНОВИТЬ КОД
+      </button>
+      <button (click)="showRecoverDialog()" mat-button>
+        <mat-icon>code</mat-icon>&nbsp;ВОССТАНОВИТЬ ДОСТУП
+      </button>
+    </mat-card-actions>
   </mat-card>
 </div>

+ 42 - 2
src/app/src/app/app.login.component.ts

@@ -1,10 +1,12 @@
-import {Component, OnInit} from "@angular/core";
+import {Component, Inject, OnInit} from "@angular/core";
 import {HttpClient} from "@angular/common/http";
 import {ReplaySubject} from "rxjs";
 import {MatSnackBar} from "@angular/material/snack-bar";
 import {PersonService} from "./person/person.service";
 import {Router} from "@angular/router";
 import {RxStompService} from "@stomp/ng2-stompjs";
+import {MatDialog, MatDialogRef} from "@angular/material/dialog";
+import {PersonDebitDialogComponent} from "./person/person.page.component";
 
 @Component({
   selector: 'app-login',
@@ -16,11 +18,12 @@ export class AppLoginComponent implements OnInit {
   public code: string
   public valid: number = null;
 
-  constructor(private personService: PersonService, private router: Router, private httpClient: HttpClient, private snackBar: MatSnackBar, private rxStompService: RxStompService) {
+  constructor(private personService: PersonService, private router: Router, private httpClient: HttpClient, private snackBar: MatSnackBar, private rxStompService: RxStompService, @Inject("_") public _: any, private matDialog: MatDialog) {
     this.qr = new ReplaySubject<QrResponse>();
   }
 
   ngOnInit(): void {
+    this.qr = new ReplaySubject<QrResponse>();
     this.personService.getCurrentPerson().subscribe(person => this.httpClient.get("/api/new/person/qr", {params: {siteId: person.siteId}}).subscribe((res: QrResponse) => this.qr.next(res), error => this.snackBar.open("Ошибка получения данных")));
   }
 
@@ -40,6 +43,43 @@ export class AppLoginComponent implements OnInit {
     }
     this.valid = null
   }
+
+  showRecoverDialog() {
+    let ref = this.matDialog.open(AppLoginRecoverDialogComponent, {
+      width: '360px', height: '300px'
+    })
+  }
+}
+
+@Component({
+  selector: "app-login-recover-dialog",
+  templateUrl: "app.login.recover.dialog.component.html"
+})
+export class AppLoginRecoverDialogComponent {
+
+  codeMask = [/[0-9a-zA-Z]/, /[0-9a-zA-Z]/, /[0-9a-zA-Z]/, /[0-9a-zA-Z]/, '-', /[0-9a-zA-Z]/, /[0-9a-zA-Z]/, /[0-9a-zA-Z]/, /[0-9a-zA-Z]/, '-', /[0-9a-zA-Z]/, /[0-9a-zA-Z]/, /[0-9a-zA-Z]/, /[0-9a-zA-Z]/, '-', /[0-9a-zA-Z]/, /[0-9a-zA-Z]/, /[0-9a-zA-Z]/, /[0-9a-zA-Z]/]
+  code: string
+  siteId: string
+  num: number
+  valid: number = null;
+
+  constructor(private dialogRef: MatDialogRef<PersonDebitDialogComponent>, private personService: PersonService, private httpClient: HttpClient, @Inject("_") public _: any) {
+    this.siteId = personService.getSiteId();
+    this.num = Math.round(1 + 15 * Math.random())
+  }
+
+  onCodeChange(e) {
+    this.valid = null
+  }
+
+  sendRecoverCode() {
+    this.httpClient.post(`/api/new/person/check16`, {num: this.num, siteId: this.siteId, code: this.code}).subscribe(res => {
+      this.valid = 1;
+    }, err => {
+      this.valid = 0;
+    })
+  }
+
 }
 
 export class QrSixResponse {

+ 15 - 0
src/app/src/app/app.login.recover.dialog.component.html

@@ -0,0 +1,15 @@
+<h2>Восстановление доступа</h2>
+<mat-dialog-content>
+  <p>Введите код восстановления №<strong>{{num}}</strong> из списка кодов восстановления, чтобы подтвердить доступ к аккаунту для данного браузера</p>
+  <mat-form-field>
+    <mat-label>Код восстановления</mat-label>
+    <input (ngModelChange)="onCodeChange($event)" [(ngModel)]="code" [textMask]="{mask: codeMask, placeholderChar: '\u2000'}" matInput type="text"/>
+    <mat-icon *ngIf="_.isNull(valid)" matSuffix>code</mat-icon>
+    <mat-icon *ngIf="valid === 1" matSuffix>done</mat-icon>
+    <mat-icon *ngIf="valid === 0" matSuffix>not_interested</mat-icon>
+  </mat-form-field>
+</mat-dialog-content>
+<mat-dialog-actions>
+  <button (click)="sendRecoverCode()" [disabled]="_.isEmpty(code)" mat-button>ВОССТАНОВИТЬ</button>
+  <button [mat-dialog-close]="{}" mat-button>ЗАКРЫТЬ</button>
+</mat-dialog-actions>

+ 11 - 4
src/app/src/app/app.module.ts

@@ -7,7 +7,7 @@ import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
 import {MatToolbarModule} from "@angular/material/toolbar";
 import {FlexLayoutModule} from "@angular/flex-layout";
 import {MatButtonModule} from "@angular/material/button";
-import {AppLoginComponent} from "./app.login.component";
+import {AppLoginComponent, AppLoginRecoverDialogComponent} from "./app.login.component";
 import {AppIndexComponent} from "./app.index.component";
 import {MatCardModule} from "@angular/material/card";
 import {MAT_SNACK_BAR_DEFAULT_OPTIONS, MatSnackBarModule} from "@angular/material/snack-bar";
@@ -21,7 +21,7 @@ import {MatIconModule} from "@angular/material/icon";
 import {PersonService} from "./person/person.service";
 import {JwtModule} from "@auth0/angular-jwt";
 import {NgxInitModule} from "ngx-init";
-import {PersonCreditDialogComponent, PersonDebitDialogComponent, PersonPageComponent} from "./person/person.page.component";
+import {PersonCreditDialogComponent, PersonDebitDialogComponent, PersonFirstRunDialogComponent, PersonPageComponent} from "./person/person.page.component";
 import {MatListModule} from "@angular/material/list";
 import {AccountService} from "./bank/account/account.service";
 import {MatProgressBarModule} from "@angular/material/progress-bar";
@@ -36,10 +36,12 @@ import {MatCheckboxModule} from "@angular/material/checkbox";
 import {DefaultErrorHandler} from "./misc/default.error.handler";
 import {PendingIndicator, PendingSnackBar} from "./misc/pending.indicator";
 import {OrgService} from "./org/org.service";
+import {TextMaskModule} from 'angular2-text-mask';
 import {RxStompService} from "@stomp/ng2-stompjs";
 import {PrivotalPageComponent} from "./org/privotal.page.component";
 import {LabeledComponent} from "./misc/labeled.component";
 import {ElsePipe} from "./misc/else.pipe";
+import * as _ from "underscore";
 
 registerLocaleData(localeRu);
 
@@ -49,10 +51,10 @@ export function jwtTokenGetter() {
 
 @NgModule({
   declarations: [
-    AppComponent, AppLoginComponent, AppIndexComponent, PersonPageComponent, PersonDebitDialogComponent, PendingSnackBar, PersonCreditDialogComponent, PrivotalPageComponent, LabeledComponent, ElsePipe,
+    AppComponent, AppLoginComponent, AppIndexComponent, PersonPageComponent, PersonDebitDialogComponent, PendingSnackBar, PersonCreditDialogComponent, PrivotalPageComponent, LabeledComponent, ElsePipe, PersonFirstRunDialogComponent, AppLoginRecoverDialogComponent
   ],
   entryComponents: [
-    PersonDebitDialogComponent, PendingSnackBar, PersonCreditDialogComponent
+    PersonDebitDialogComponent, PendingSnackBar, PersonCreditDialogComponent, PersonFirstRunDialogComponent, AppLoginRecoverDialogComponent
   ],
   imports: [
     BrowserModule,
@@ -69,6 +71,7 @@ export function jwtTokenGetter() {
     MatInputModule,
     MatIconModule,
     NgxInitModule,
+    TextMaskModule,
     JwtModule.forRoot({
       config: {
         tokenGetter: jwtTokenGetter,
@@ -99,6 +102,10 @@ export function jwtTokenGetter() {
       useClass: PendingIndicator,
       multi: true
     },
+    {
+      provide: "_",
+      useValue: _
+    },
     RxStompService
   ],
   bootstrap: [AppComponent]

+ 11 - 0
src/app/src/app/person/person.first.run.dialog.component.html

@@ -0,0 +1,11 @@
+<h2>Добро пожаловать!</h2>
+<mat-dialog-content>
+  <p>Это коды восстановления доступа, сохраните их, это ваша единственная возможность восстановить доступ к аккаунту</p>
+  <ul>
+    <li *ngFor="let c of data.codes">{{c}}</li>
+  </ul>
+</mat-dialog-content>
+<mat-dialog-actions fxLayout="row" fxLayoutAlign="end center">
+  <button (click)="getCodes()" mat-button>СОХРАНИТЬ</button>
+  <button [mat-dialog-close]="{}" mat-button>ЗАКРЫТЬ</button>
+</mat-dialog-actions>

+ 38 - 1
src/app/src/app/person/person.page.component.ts

@@ -1,6 +1,6 @@
 import {Component, Inject, OnDestroy, OnInit} from "@angular/core";
 import {AccountEvent, AccountInfo, AccountService} from "../bank/account/account.service";
-import {PersonService} from "./person.service";
+import {PersonFirstRun, PersonService} from "./person.service";
 import {HttpClient} from "@angular/common/http";
 import * as _ from 'underscore'
 import {BankService, DemurrageInfo} from "../bank/bank.service";
@@ -9,6 +9,7 @@ import {AppComponent} from "../app.component";
 import {OrgInfo, OrgService} from "../org/org.service";
 import {RxStompService} from "@stomp/ng2-stompjs";
 import {Subscription} from "rxjs";
+import * as jsPDF from 'jspdf/dist/jspdf.min'
 
 @Component({
   selector: 'person-page',
@@ -39,6 +40,11 @@ export class PersonPageComponent implements OnInit, OnDestroy {
   updateNow(): void {
     this.personService.getCurrentPerson().subscribe(person => {
       this.orgService.getOrgsInfo(person.auth.id).subscribe(orgs => this.orgs = orgs);
+      this.personService.getFirstRunInfo().subscribe(firstRun => {
+        if (firstRun.firstRun) {
+          this.showFirstRunDialog(firstRun)
+        }
+      });
       this.accountService.getAccountsInfo(person.auth.id).subscribe(accounts => {
         this.accounts = accounts
         let accountIds = accounts.map(e => e.id)
@@ -117,6 +123,14 @@ export class PersonPageComponent implements OnInit, OnDestroy {
       })
     });
   }
+
+  showFirstRunDialog(fr: PersonFirstRun) {
+    this.personService.getCurrentPerson().subscribe(person => {
+      let ref = this.matDialog.open(PersonFirstRunDialogComponent, {
+        width: '400px', height: '572px', data: fr
+      })
+    })
+  }
 }
 
 @Component({
@@ -191,6 +205,29 @@ export class PersonCreditDialogComponent {
 
 }
 
+@Component({
+  selector: 'person-first-run-dialog',
+  templateUrl: 'person.first.run.dialog.component.html',
+  styleUrls: []
+})
+export class PersonFirstRunDialogComponent {
+
+  constructor(private dialogRef: MatDialogRef<PersonDebitDialogComponent>, @Inject(MAT_DIALOG_DATA) public data: PersonFirstRun) {
+
+  }
+
+  getCodes() {
+    var doc = new jsPDF()
+
+    doc.text("FG recovery code list", 10, 10)
+
+    this.data.codes.forEach((c, i) => {
+      doc.text(`${i + 1}) ${c}`, 10, 20 + i * 10)
+    })
+
+    doc.save('fg-codes.pdf')
+  }
+}
 
 abstract class PersonAccountData {
   account: AccountInfo

+ 16 - 1
src/app/src/app/person/person.service.ts

@@ -7,6 +7,7 @@ import {ActivatedRouteSnapshot, CanActivate, CanActivateChild, RouterStateSnapsh
 import {map} from "rxjs/operators";
 import {InjectableRxStompConfig, RxStompService} from "@stomp/ng2-stompjs";
 import {myRxStompConfig} from "../stomp.config";
+import {HttpClient} from "@angular/common/http";
 
 @Injectable({providedIn: "root"})
 export class PersonService implements CanActivate, CanActivateChild {
@@ -15,7 +16,7 @@ export class PersonService implements CanActivate, CanActivateChild {
   private helper = new JwtHelperService();
   private currentPerson: Observable<Person>
 
-  constructor(private rxStompService: RxStompService) {
+  constructor(private rxStompService: RxStompService, private httpClient: HttpClient) {
     if (!(this.siteId = localStorage.siteId)) {
       localStorage.siteId = this.siteId = uuidv4()
     }
@@ -81,6 +82,15 @@ export class PersonService implements CanActivate, CanActivateChild {
     return this.currentPerson;
   }
 
+  getFirstRunInfo(): Observable<PersonFirstRun> {
+    let ret = new ReplaySubject<PersonFirstRun>();
+    this.getCurrentPerson().subscribe(person => this.httpClient.get(`/api/old/person/${person.auth.id}/first-run`).pipe(map((r: any) => <PersonFirstRun>r)).subscribe(fr => ret.next(fr)))
+    return ret;
+  }
+
+  getSiteId(): string {
+    return this.siteId
+  }
 }
 
 export class Person {
@@ -91,3 +101,8 @@ export class Person {
 export class PersonInfo {
   id: string
 }
+
+export class PersonFirstRun {
+  firstRun: boolean
+  codes: string[]
+}

+ 247 - 6
src/app/yarn.lock

@@ -1373,6 +1373,11 @@ JSONStream@^1.3.4:
     jsonparse "^1.2.0"
     through ">=2.2.7 <3"
 
+abab@^1.0.0:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/abab/-/abab-1.0.4.tgz#5faad9c2c07f60dd76770f71cf025b62a63cfd4e"
+  integrity sha1-X6rZwsB/YN12dw9xzwJbYqY8/U4=
+
 abab@^2.0.3:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.3.tgz#623e2075e02eb2d3f2475e49f99c91846467907a"
@@ -1386,6 +1391,18 @@ accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7:
     mime-types "~2.1.24"
     negotiator "0.6.2"
 
+acorn-globals@^1.0.4:
+  version "1.0.9"
+  resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-1.0.9.tgz#55bb5e98691507b74579d0513413217c380c54cf"
+  integrity sha1-VbtemGkVB7dFedBRNBMhfDgMVM8=
+  dependencies:
+    acorn "^2.1.0"
+
+acorn@^2.1.0, acorn@^2.4.0:
+  version "2.7.0"
+  resolved "https://registry.yarnpkg.com/acorn/-/acorn-2.7.0.tgz#ab6e7d9d886aaca8b085bc3312b79a198433f0e7"
+  integrity sha1-q259nYhqrKiwhbwzEreaGYQz8Oc=
+
 acorn@^6.4.1:
   version "6.4.1"
   resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.4.1.tgz#531e58ba3f51b9dacb9a6646ca4debf5b14ca474"
@@ -1456,6 +1473,13 @@ alphanum-sort@^1.0.0:
   resolved "https://registry.yarnpkg.com/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3"
   integrity sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM=
 
+angular2-text-mask@^9.0.0:
+  version "9.0.0"
+  resolved "https://registry.yarnpkg.com/angular2-text-mask/-/angular2-text-mask-9.0.0.tgz#70490170a8096241fc3ce9482ed6a758ddbce8ea"
+  integrity sha512-iALcnhJPS1zvX48d86rgUgDe/crX6XfhZrXC4Gdlo2/YwZW7u7KJZY6/b3ieSCIWVq/E6p+wDCzeo3E6leRjDA==
+  dependencies:
+    text-mask-core "^5.0.0"
+
 angular2-uuid@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/angular2-uuid/-/angular2-uuid-1.1.1.tgz#72f03cd532b7f40032eb1ecfb9f8457384be956e"
@@ -1566,6 +1590,11 @@ arr-union@^3.1.0:
   resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4"
   integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=
 
+array-equal@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/array-equal/-/array-equal-1.0.0.tgz#8c2a5ef2472fd9ea742b04c77a75093ba2757c93"
+  integrity sha1-jCpe8kcv2ep0KwTHenUJO6J1fJM=
+
 array-flatten@1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2"
@@ -1717,6 +1746,11 @@ balanced-match@^1.0.0:
   resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
   integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c=
 
+base64-arraybuffer@^0.1.5:
+  version "0.1.5"
+  resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz#73926771923b5a19747ad666aa5cd4bf9c6e9ce8"
+  integrity sha1-c5JncZI7Whl0etZmqlzUv5xunOg=
+
 base64-js@^1.0.2:
   version "1.3.1"
   resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1"
@@ -2099,6 +2133,16 @@ canonical-path@1.0.0:
   resolved "https://registry.yarnpkg.com/canonical-path/-/canonical-path-1.0.0.tgz#fcb470c23958def85081856be7a86e904f180d1d"
   integrity sha512-feylzsbDxi1gPZ1IjystzIQZagYYLvfKrSuygUCgf7z6x790VEzze5QEkdSV1U58RA7Hi0+v6fv4K54atOzATg==
 
+canvg@1.5.3:
+  version "1.5.3"
+  resolved "https://registry.yarnpkg.com/canvg/-/canvg-1.5.3.tgz#aad17915f33368bf8eb80b25d129e3ae922ddc5f"
+  integrity sha512-7Gn2IuQzvUQWPIuZuFHrzsTM0gkPz2RRT9OcbdmA03jeKk8kltrD8gqUzNX15ghY/4PV5bbe5lmD6yDLDY6Ybg==
+  dependencies:
+    jsdom "^8.1.0"
+    rgbcolor "^1.0.1"
+    stackblur-canvas "^1.4.1"
+    xmldom "^0.1.22"
+
 caseless@~0.12.0:
   version "0.12.0"
   resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
@@ -2563,6 +2607,13 @@ css-declaration-sorter@^4.0.1:
     postcss "^7.0.1"
     timsort "^0.3.0"
 
+css-line-break@1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/css-line-break/-/css-line-break-1.0.1.tgz#19f2063a33e95fb2831b86446c0b80c188af450a"
+  integrity sha1-GfIGOjPpX7KDG4ZEbAuAwYivRQo=
+  dependencies:
+    base64-arraybuffer "^0.1.5"
+
 css-loader@3.5.3:
   version "3.5.3"
   resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-3.5.3.tgz#95ac16468e1adcd95c844729e0bb167639eb0bcf"
@@ -2715,6 +2766,18 @@ csso@^4.0.2:
   dependencies:
     css-tree "1.0.0-alpha.39"
 
+cssom@0.3.x, "cssom@>= 0.3.0 < 0.4.0":
+  version "0.3.8"
+  resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.8.tgz#9f1276f5b2b463f2114d3f2c75250af8c1a36f4a"
+  integrity sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==
+
+"cssstyle@>= 0.2.34 < 0.3.0":
+  version "0.2.37"
+  resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-0.2.37.tgz#541097234cb2513c83ceed3acddc27ff27987d54"
+  integrity sha1-VBCXI0yyUTyDzu06zdwn/yeYfVQ=
+  dependencies:
+    cssom "0.3.x"
+
 cyclist@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9"
@@ -2799,6 +2862,11 @@ deep-equal@^1.0.1:
     object-keys "^1.1.1"
     regexp.prototype.flags "^1.2.0"
 
+deep-is@~0.1.3:
+  version "0.1.3"
+  resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34"
+  integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=
+
 default-gateway@^4.2.0:
   version "4.2.0"
   resolved "https://registry.yarnpkg.com/default-gateway/-/default-gateway-4.2.0.tgz#167104c7500c2115f6dd69b0a536bb8ed720552b"
@@ -3177,6 +3245,18 @@ escape-string-regexp@^1.0.5:
   resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
   integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=
 
+escodegen@^1.6.1:
+  version "1.14.3"
+  resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.14.3.tgz#4e7b81fba61581dc97582ed78cab7f0e8d63f503"
+  integrity sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==
+  dependencies:
+    esprima "^4.0.1"
+    estraverse "^4.2.0"
+    esutils "^2.0.2"
+    optionator "^0.8.1"
+  optionalDependencies:
+    source-map "~0.6.1"
+
 eslint-scope@^4.0.3:
   version "4.0.3"
   resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.3.tgz#ca03833310f6889a3264781aa82e63eb9cfe7848"
@@ -3185,7 +3265,7 @@ eslint-scope@^4.0.3:
     esrecurse "^4.1.0"
     estraverse "^4.1.1"
 
-esprima@^4.0.0:
+esprima@^4.0.0, esprima@^4.0.1:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
   integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
@@ -3197,7 +3277,7 @@ esrecurse@^4.1.0:
   dependencies:
     estraverse "^4.1.0"
 
-estraverse@^4.1.0, estraverse@^4.1.1:
+estraverse@^4.1.0, estraverse@^4.1.1, estraverse@^4.2.0:
   version "4.3.0"
   resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d"
   integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==
@@ -3381,6 +3461,11 @@ fast-json-stable-stringify@2.1.0, fast-json-stable-stringify@^2.0.0:
   resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
   integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
 
+fast-levenshtein@~2.0.6:
+  version "2.0.6"
+  resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
+  integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=
+
 fastq@^1.6.0:
   version "1.8.0"
   resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.8.0.tgz#550e1f9f59bbc65fe185cb6a9b4d95357107f481"
@@ -3422,6 +3507,10 @@ file-loader@6.0.0:
     loader-utils "^2.0.0"
     schema-utils "^2.6.5"
 
+"file-saver@github:eligrey/FileSaver.js#1.3.8":
+  version "1.3.8"
+  resolved "https://codeload.github.com/eligrey/FileSaver.js/tar.gz/e865e37af9f9947ddcced76b549e27dc45c1cb2e"
+
 file-uri-to-path@1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd"
@@ -3841,6 +3930,13 @@ html-entities@^1.3.1:
   resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.3.1.tgz#fb9a1a4b5b14c5daba82d3e34c6ae4fe701a0e44"
   integrity sha512-rhE/4Z3hIhzHAUKbW8jVcCyuT5oJCXXqhN/6mXXVCpzTmvJnoH2HL/bt3EZ6p55jbFJBeAe1ZNpL5BugLujxNA==
 
+html2canvas@1.0.0-alpha.12:
+  version "1.0.0-alpha.12"
+  resolved "https://registry.yarnpkg.com/html2canvas/-/html2canvas-1.0.0-alpha.12.tgz#3b1992e3c9b3f56063c35fd620494f37eba88513"
+  integrity sha1-OxmS48mz9WBjw1/WIElPN+uohRM=
+  dependencies:
+    css-line-break "1.0.1"
+
 http-cache-semantics@^3.8.1:
   version "3.8.1"
   resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-3.8.1.tgz#39b0e16add9b605bf0a9ef3d9daaf4843b4cacd2"
@@ -3944,7 +4040,7 @@ humanize-ms@^1.2.1:
   dependencies:
     ms "^2.0.0"
 
-iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@~0.4.13:
+iconv-lite@0.4.24, iconv-lite@^0.4.13, iconv-lite@^0.4.24, iconv-lite@~0.4.13:
   version "0.4.24"
   resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
   integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
@@ -4464,6 +4560,29 @@ jsbn@~0.1.0:
   resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
   integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM=
 
+jsdom@^8.1.0:
+  version "8.5.0"
+  resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-8.5.0.tgz#d4d8f5dbf2768635b62a62823b947cf7071ebc98"
+  integrity sha1-1Nj12/J2hjW2KmKCO5R89wcevJg=
+  dependencies:
+    abab "^1.0.0"
+    acorn "^2.4.0"
+    acorn-globals "^1.0.4"
+    array-equal "^1.0.0"
+    cssom ">= 0.3.0 < 0.4.0"
+    cssstyle ">= 0.2.34 < 0.3.0"
+    escodegen "^1.6.1"
+    iconv-lite "^0.4.13"
+    nwmatcher ">= 1.3.7 < 2.0.0"
+    parse5 "^1.5.1"
+    request "^2.55.0"
+    sax "^1.1.4"
+    symbol-tree ">= 3.1.0 < 4.0.0"
+    tough-cookie "^2.2.0"
+    webidl-conversions "^3.0.1"
+    whatwg-url "^2.0.1"
+    xml-name-validator ">= 2.0.1 < 3.0.0"
+
 jsesc@^2.5.1:
   version "2.5.2"
   resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4"
@@ -4525,6 +4644,18 @@ jsonparse@^1.2.0:
   resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280"
   integrity sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=
 
+jspdf@^1.5.3:
+  version "1.5.3"
+  resolved "https://registry.yarnpkg.com/jspdf/-/jspdf-1.5.3.tgz#5a12c011479defabef5735de55c913060ed219f2"
+  integrity sha512-J9X76xnncMw+wIqb15HeWfPMqPwYxSpPY8yWPJ7rAZN/ZDzFkjCSZObryCyUe8zbrVRNiuCnIeQteCzMn7GnWw==
+  dependencies:
+    canvg "1.5.3"
+    file-saver eligrey/FileSaver.js#1.3.8
+    html2canvas "1.0.0-alpha.12"
+    omggif "1.0.7"
+    promise-polyfill "8.1.0"
+    stackblur-canvas "2.2.0"
+
 jsprim@^1.2.2:
   version "1.4.1"
   resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2"
@@ -4610,6 +4741,14 @@ levenary@^1.1.1:
   dependencies:
     leven "^3.1.0"
 
+levn@~0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee"
+  integrity sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=
+  dependencies:
+    prelude-ls "~1.1.2"
+    type-check "~0.3.2"
+
 license-webpack-plugin@2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/license-webpack-plugin/-/license-webpack-plugin-2.2.0.tgz#5c964380d7d0e0c27c349d86a6f856c82924590e"
@@ -5313,6 +5452,11 @@ num2fraction@^1.2.2:
   resolved "https://registry.yarnpkg.com/num2fraction/-/num2fraction-1.2.2.tgz#6f682b6a027a4e9ddfa4564cd2589d1d4e669ede"
   integrity sha1-b2gragJ6Tp3fpFZM0lidHU5mnt4=
 
+"nwmatcher@>= 1.3.7 < 2.0.0":
+  version "1.4.4"
+  resolved "https://registry.yarnpkg.com/nwmatcher/-/nwmatcher-1.4.4.tgz#2285631f34a95f0d0395cd900c96ed39b58f346e"
+  integrity sha512-3iuY4N5dhgMpCUrOVnuAdGrgxVqV2cJpM+XNccjR2DKOB1RUP0aA+wGXEiNziG/UKboFyGBIoKOaNlJxx8bciQ==
+
 oauth-sign@~0.9.0:
   version "0.9.0"
   resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455"
@@ -5402,6 +5546,11 @@ obuf@^1.0.0, obuf@^1.1.2:
   resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e"
   integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==
 
+omggif@1.0.7:
+  version "1.0.7"
+  resolved "https://registry.yarnpkg.com/omggif/-/omggif-1.0.7.tgz#59d2eecb0263de84635b3feb887c0c9973f1e49d"
+  integrity sha1-WdLuywJj3oRjWz/riHwMmXPx5J0=
+
 on-finished@~2.3.0:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
@@ -5443,6 +5592,18 @@ opn@^5.5.0:
   dependencies:
     is-wsl "^1.1.0"
 
+optionator@^0.8.1:
+  version "0.8.3"
+  resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495"
+  integrity sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==
+  dependencies:
+    deep-is "~0.1.3"
+    fast-levenshtein "~2.0.6"
+    levn "~0.3.0"
+    prelude-ls "~1.1.2"
+    type-check "~0.3.2"
+    word-wrap "~1.2.3"
+
 ora@4.0.4:
   version "4.0.4"
   resolved "https://registry.yarnpkg.com/ora/-/ora-4.0.4.tgz#e8da697cc5b6a47266655bf68e0fb588d29a545d"
@@ -5619,6 +5780,11 @@ parse5@4.0.0:
   resolved "https://registry.yarnpkg.com/parse5/-/parse5-4.0.0.tgz#6d78656e3da8d78b4ec0b906f7c08ef1dfe3f608"
   integrity sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA==
 
+parse5@^1.5.1:
+  version "1.5.1"
+  resolved "https://registry.yarnpkg.com/parse5/-/parse5-1.5.1.tgz#9b7f3b0de32be78dc2401b17573ccaf0f6f59d94"
+  integrity sha1-m387DeMr543CQBsXVzzK8Pb1nZQ=
+
 parse5@^5.0.0:
   version "5.1.1"
   resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178"
@@ -6125,6 +6291,11 @@ postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.16, postcss@^7.0.2
     source-map "^0.6.1"
     supports-color "^6.1.0"
 
+prelude-ls@~1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
+  integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=
+
 prepend-http@^1.0.0:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc"
@@ -6145,6 +6316,11 @@ promise-inflight@^1.0.1:
   resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3"
   integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM=
 
+promise-polyfill@8.1.0:
+  version "8.1.0"
+  resolved "https://registry.yarnpkg.com/promise-polyfill/-/promise-polyfill-8.1.0.tgz#30059da54d1358ce905ac581f287e184aedf995d"
+  integrity sha512-OzSf6gcCUQ01byV4BgwyUCswlaQQ6gzXc23aLQWhicvfX9kfsUiUhgt3CCQej8jDnl8/PhGF31JdHX2/MzF3WA==
+
 promise-retry@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/promise-retry/-/promise-retry-1.1.1.tgz#6739e968e3051da20ce6497fb2b50f6911df3d6d"
@@ -6478,7 +6654,7 @@ repeat-string@^1.6.1:
   resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637"
   integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc=
 
-request@^2.83.0, request@^2.88.0:
+request@^2.55.0, request@^2.83.0, request@^2.88.0:
   version "2.88.2"
   resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3"
   integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==
@@ -6610,6 +6786,11 @@ rgba-regex@^1.0.0:
   resolved "https://registry.yarnpkg.com/rgba-regex/-/rgba-regex-1.0.0.tgz#43374e2e2ca0968b0ef1523460b7d730ff22eeb3"
   integrity sha1-QzdOLiyglosO8VI0YLfXMP8i7rM=
 
+rgbcolor@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/rgbcolor/-/rgbcolor-1.0.1.tgz#d6505ecdb304a6595da26fa4b43307306775945d"
+  integrity sha1-1lBezbMEplldom+ktDMHMGd1lF0=
+
 rimraf@3.0.2, rimraf@^3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
@@ -6710,7 +6891,7 @@ sass@1.26.5:
   dependencies:
     chokidar ">=2.0.0 <4.0.0"
 
-sax@~1.2.4:
+sax@^1.1.4, sax@~1.2.4:
   version "1.2.4"
   resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
   integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
@@ -7148,6 +7329,16 @@ stable@^0.1.8:
   resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf"
   integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==
 
+stackblur-canvas@2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/stackblur-canvas/-/stackblur-canvas-2.2.0.tgz#cacc5924a0744b3e183eb2e6c1d8559c1a17c26e"
+  integrity sha512-5Gf8dtlf8k6NbLzuly2NkGrkS/Ahh+I5VUjO7TnFizdJtgpfpLLEdQlLe9umbcnZlitU84kfYjXE67xlSXfhfQ==
+
+stackblur-canvas@^1.4.1:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/stackblur-canvas/-/stackblur-canvas-1.4.1.tgz#849aa6f94b272ff26f6471fa4130ed1f7e47955b"
+  integrity sha1-hJqm+UsnL/JvZHH6QTDtH35HlVs=
+
 static-extend@^0.1.1:
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6"
@@ -7357,6 +7548,11 @@ symbol-observable@1.2.0:
   resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804"
   integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==
 
+"symbol-tree@>= 3.1.0 < 4.0.0":
+  version "3.2.4"
+  resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
+  integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==
+
 tapable@^1.0.0, tapable@^1.1.3:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2"
@@ -7435,6 +7631,11 @@ terser@^4.1.2, terser@^4.6.13:
     source-map "~0.6.1"
     source-map-support "~0.5.12"
 
+text-mask-core@^5.0.0:
+  version "5.1.2"
+  resolved "https://registry.yarnpkg.com/text-mask-core/-/text-mask-core-5.1.2.tgz#80dd5ebe04825757e46619e691407a9f8b3c1b6f"
+  integrity sha1-gN1evgSCV1fkZhnmkUB6n4s8G28=
+
 through2@^2.0.0:
   version "2.0.5"
   resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd"
@@ -7519,7 +7720,7 @@ toidentifier@1.0.0:
   resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
   integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
 
-tough-cookie@~2.5.0:
+tough-cookie@^2.2.0, tough-cookie@~2.5.0:
   version "2.5.0"
   resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2"
   integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==
@@ -7534,6 +7735,11 @@ tr46@^2.0.2:
   dependencies:
     punycode "^2.1.1"
 
+tr46@~0.0.3:
+  version "0.0.3"
+  resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
+  integrity sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=
+
 tree-kill@1.2.2:
   version "1.2.2"
   resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc"
@@ -7582,6 +7788,13 @@ tweetnacl@^0.14.3, tweetnacl@~0.14.0:
   resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
   integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=
 
+type-check@~0.3.2:
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72"
+  integrity sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=
+  dependencies:
+    prelude-ls "~1.1.2"
+
 type-fest@^0.11.0:
   version "0.11.0"
   resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.11.0.tgz#97abf0872310fed88a5c466b25681576145e33f1"
@@ -7882,6 +8095,11 @@ wcwidth@^1.0.1:
   dependencies:
     defaults "^1.0.3"
 
+webidl-conversions@^3.0.0, webidl-conversions@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
+  integrity sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=
+
 webidl-conversions@^5.0.0:
   version "5.0.0"
   resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-5.0.0.tgz#ae59c8a00b121543a2acc65c0434f57b0fc11aff"
@@ -8022,6 +8240,14 @@ whatwg-mimetype@^2.3.0:
   resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf"
   integrity sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==
 
+whatwg-url@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-2.0.1.tgz#5396b2043f020ee6f704d9c45ea8519e724de659"
+  integrity sha1-U5ayBD8CDub3BNnEXqhRnnJN5lk=
+  dependencies:
+    tr46 "~0.0.3"
+    webidl-conversions "^3.0.0"
+
 whatwg-url@^8.0.0:
   version "8.1.0"
   resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-8.1.0.tgz#c628acdcf45b82274ce7281ee31dd3c839791771"
@@ -8048,6 +8274,11 @@ which@^1.2.9, which@^1.3.1:
   dependencies:
     isexe "^2.0.0"
 
+word-wrap@~1.2.3:
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
+  integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
+
 worker-farm@^1.7.0:
   version "1.7.0"
   resolved "https://registry.yarnpkg.com/worker-farm/-/worker-farm-1.7.0.tgz#26a94c5391bbca926152002f69b84a4bf772e5a8"
@@ -8092,6 +8323,16 @@ ws@^6.2.1:
   dependencies:
     async-limiter "~1.0.0"
 
+"xml-name-validator@>= 2.0.1 < 3.0.0":
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-2.0.1.tgz#4d8b8f1eccd3419aa362061becef515e1e559635"
+  integrity sha1-TYuPHszTQZqjYgYb7O9RXh5VljU=
+
+xmldom@^0.1.22:
+  version "0.1.31"
+  resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.31.tgz#b76c9a1bd9f0a9737e5a72dc37231cf38375e2ff"
+  integrity sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ==
+
 xtend@^4.0.0, xtend@~4.0.1:
   version "4.0.2"
   resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"

+ 13 - 0
src/main/java/inn/ocsf/bee/freigeld/core/model/data/PersonIdentityPrj0.java

@@ -0,0 +1,13 @@
+package inn.ocsf.bee.freigeld.core.model.data;
+
+import inn.ocsf.bee.freigeld.core.model.PersonIdentity;
+import org.bson.types.ObjectId;
+
+import java.util.Map;
+
+public interface PersonIdentityPrj0 {
+
+    ObjectId getId();
+
+    Map<String, PersonIdentity> getIdentities();
+}

+ 5 - 0
src/main/java/inn/ocsf/bee/freigeld/core/repo/PersonRepository.java

@@ -2,8 +2,10 @@ package inn.ocsf.bee.freigeld.core.repo;
 
 import inn.ocsf.bee.freigeld.core.model.data.PersonData;
 import inn.ocsf.bee.freigeld.core.model.data.PersonDataPrj0;
+import inn.ocsf.bee.freigeld.core.model.data.PersonIdentityPrj0;
 import org.bson.types.ObjectId;
 import org.springframework.data.mongodb.repository.MongoRepository;
+import org.springframework.data.mongodb.repository.Query;
 import org.springframework.data.querydsl.QuerydslPredicateExecutor;
 
 import java.util.Optional;
@@ -15,4 +17,7 @@ public interface PersonRepository extends MongoRepository<PersonData, ObjectId>,
     Optional<PersonData> findOneByPersonId(UUID person_id);
 
     Stream<PersonDataPrj0> findAllBy();
+
+    @Query("{}")
+    Stream<PersonIdentityPrj0> findAllBy0();
 }

+ 10 - 0
src/main/kotlin/inn/ocsf/bee/freigeld/core/service/WorldService.kt

@@ -1,5 +1,8 @@
 package inn.ocsf.bee.freigeld.core.service
 
+import inn.ocsf.bee.freigeld.core.model.GlobalWorld
+import inn.ocsf.bee.freigeld.core.model.Person
+import inn.ocsf.bee.freigeld.core.model.PersonIdentitySecret
 import inn.ocsf.bee.freigeld.core.model.PersonMemberPosition
 import inn.ocsf.bee.freigeld.core.repo.PersonRepository
 import org.springframework.stereotype.Service
@@ -13,6 +16,9 @@ class WorldService {
     @Inject
     private lateinit var personRepo: PersonRepository
 
+    @Inject
+    private lateinit var world: GlobalWorld
+
     fun findAllByOwnerId(ownerId: UUID): Set<UUID> {
         return personRepo.findAllByOwnerId(ownerId).map { it.person }.map { it.id }.toSet()
     }
@@ -21,4 +27,8 @@ class WorldService {
         return personRepo.findAllByMemberId(memberId).toList()
     }
 
+    fun findOneByRecoveryCode(code: String, num: Int): Person? {
+        return personRepo.findAllBy0().flatMap { p -> p.identities.entries.stream().map { p.id to it.value } }.filter { it.second is PersonIdentitySecret }.map { it.first to (it.second as PersonIdentitySecret) }.filter { it.second.codes?.get(num - 1)?.equals(code) ?: false }.map { personRepo.findById(it.first) }.filter { it.isPresent }.map { it.get() }.map { it.person }.findAny().orElse(null)
+    }
+
 }

+ 46 - 6
src/main/kotlin/inn/ocsf/bee/freigeld/serve/rest/PersonController.kt

@@ -18,9 +18,9 @@ import inn.ocsf.bee.freigeld.core.model.data.PersonData
 import inn.ocsf.bee.freigeld.core.repo.PersonRepository
 import inn.ocsf.bee.freigeld.core.repo.PublicLinkRepository
 import inn.ocsf.bee.freigeld.core.service.TicketService
+import inn.ocsf.bee.freigeld.core.service.WorldService
 import inn.ocsf.bee.freigeld.serve.JwtAuthenticationController
-import inn.ocsf.bee.freigeld.utils.KeyValueBucket.personToSite
-import inn.ocsf.bee.freigeld.utils.KeyValueBucket.secretToSite
+import inn.ocsf.bee.freigeld.utils.KeyValueBucket.*
 import inn.ocsf.bee.freigeld.utils.KeyValueStorage
 import org.apache.commons.lang3.RandomStringUtils
 import org.slf4j.LoggerFactory
@@ -54,6 +54,9 @@ class PersonController {
     @Inject
     private lateinit var totp: PersonTotpService
 
+    @Inject
+    private lateinit var storage: KeyValueStorage
+
     private val log = LoggerFactory.getLogger(javaClass)
 
     @RequestMapping("/api/old/person/{personId}/get-credit-link/{linkId}", method = [RequestMethod.GET])
@@ -105,6 +108,18 @@ class PersonController {
         return ResponseEntity.ok(events)
     }
 
+    @RequestMapping("api/old/person/{personId}/first-run", method = [RequestMethod.GET])
+    fun firstRun(@PathVariable("personId") personId: UUID): ResponseEntity<PersonFirstRunInfoResp> {
+        val firstRun = storage.get<Boolean>(personFirstRun, personId) ?: false
+        val codes = if (firstRun) {
+            storage.put(personFirstRun, personId, false)
+            world.getPerson(personId)?.let { person -> world.getPersonIdentitySet(person)?.filter { it is PersonIdentitySecret }?.map { it as PersonIdentitySecret }?.firstOrNull()?.codes }
+        } else {
+            null
+        }
+        return ResponseEntity.ok(PersonFirstRunInfoResp(firstRun, codes))
+    }
+
     @RequestMapping("api/old/person/{personId}/accounts/add", method = [RequestMethod.POST])
     fun addAccount(@PathVariable("personId") personId: UUID): ResponseEntity<Map<String, Any?>> {
         val account = bank.addAccount(personId)
@@ -165,6 +180,8 @@ class PersonController {
     }
 }
 
+data class PersonFirstRunInfoResp(val firstRun: Boolean, val codes: List<String>?)
+
 data class CreditLinkReq(val code: String?, val amount: Long?)
 
 data class DebitLinkReq(val amount: Long?, val once: Boolean?, val limited: Boolean?, val time: Long?)
@@ -244,6 +261,9 @@ class PersonAuthController : JwtAuthenticationController() {
     @Inject
     private lateinit var world: GlobalWorld
 
+    @Inject
+    private lateinit var worldService: WorldService
+
     @Inject
     private lateinit var totp: PersonTotpService
 
@@ -258,6 +278,17 @@ class PersonAuthController : JwtAuthenticationController() {
         return ResponseEntity.ok(QrDataResponse(secret, dataUri))
     }
 
+    @RequestMapping("api/new/person/check16", method = [RequestMethod.POST])
+    fun checkrecover(@RequestBody req: RecoverOldReq): ResponseEntity<RecoverOldResp> {
+        val person = worldService.findOneByRecoveryCode(req.code, req.num)
+        return if (person != null) {
+            storage.put(siteToPerson, req.siteId, person.id)
+            ResponseEntity.ok(RecoverOldResp(req.siteId))
+        } else {
+            ResponseEntity(HttpStatus.NOT_FOUND)
+        }
+    }
+
     @RequestMapping("api/old/person/{personId}/check6", method = [RequestMethod.POST])
     fun checkqrnow(@PathVariable("personId") personId: UUID, @RequestBody rq: QrOldSixRequest): ResponseEntity<QrOldSixResponse> {
         val verifier = totp.getVerifier()
@@ -287,14 +318,19 @@ class PersonAuthController : JwtAuthenticationController() {
             val recoveryCodeGenerator = RecoveryCodeGenerator()
             val codes = recoveryCodeGenerator.generateCodes(16)
             world.setPersonIdentity(person, PersonIdentitySecret(req.key, codes.toList()))
-            storage.put(personToSite, person.id, req.siteId)
+            storage.put(siteToPerson, req.siteId, person.id)
+            storage.put(personFirstRun, person.id, true)
             register = true
             person
         } else {
-            storage.getByValue<UUID>(personToSite, req.siteId).map { id -> personRepo.findOneByPersonId(id).orElseThrow { throw RuntimeException("strange person ${id} not found") } }.filterNotNull().filter {
+            storage.get<UUID>(siteToPerson, req.siteId).let { id -> personRepo.findOneByPersonId(id).orElseThrow { throw RuntimeException("strange person ${id} not found") } }.let {
                 val secret = it.identities.values.filter { it is PersonIdentitySecret }.first()
-                verifier.isValidCode(secret.key, req.code)
-            }.map { it.person }.firstOrNull()
+                if (verifier.isValidCode(secret.key, req.code)) {
+                    it.person
+                } else {
+                    null
+                }
+            }
         }
         val token = thisPerson?.let { createAuthenticationToken(it.id.toString()) }
         return ResponseEntity.ok(QrSixResponse(thisPerson != null, register, token))
@@ -302,6 +338,10 @@ class PersonAuthController : JwtAuthenticationController() {
 
 }
 
+data class RecoverOldReq(val num: Int, val siteId: UUID, val code: String)
+
+data class RecoverOldResp(val siteId: UUID)
+
 data class QrDataResponse(val key: String, val img: String)
 
 data class QrSixRequest(val siteId: UUID, val code: String, val key: String)

+ 5 - 1
src/main/kotlin/inn/ocsf/bee/freigeld/utils/KeyValueStorage.kt

@@ -21,6 +21,7 @@ class KeyValueStorage {
             if (it.keyStr == null) it.keyStr = getKeyStr(it.key!!)
             keyValueRepo.save(it)
         }
+        //keyValueRepo.findAll().filter { it.bucket == KeyValueBucket.personToSite }.forEach { put(KeyValueBucket.siteToPerson, it.value!!, it.key!!) }
     }
 
     private fun getKeyStr(key: Any): String {
@@ -66,10 +67,13 @@ class KeyValueStorage {
 }
 
 enum class KeyValueBucket(val bucket: String) {
+    siteToPerson("site-to-person"),
+    @Deprecated("wrong relation model")
     personToSite("person-to-site"),
     emitterToPlan("emitter-to-plan"),
     secretToSite("secret-to-site"),
-    personToLabel("person-to-label");
+    personToLabel("person-to-label"),
+    personFirstRun("person-first-run")
 }
 
 interface KeyValueRepository : MongoRepository<KeyValueObject, ObjectId> {