From ac0c41aeabd06177f1e87c6f854f301bea54af2b Mon Sep 17 00:00:00 2001 From: Benoit Lavenier <benoit.lavenier@e-is.pro> Date: Wed, 31 Aug 2022 20:09:16 +0200 Subject: [PATCH] [enh] Register: finish register process [enh] Home: detect if login or not [enh] Account: restore accounts from storage --- package.json | 1 + src/app/app.component.ts | 7 +- src/app/app.module.ts | 15 +- src/app/auth/auth.model.ts | 5 + src/app/home/home.page.html | 46 ++- src/app/home/home.page.ts | 60 +++- src/app/network/network.service.ts | 8 +- src/app/register/register.form.html | 62 ++-- src/app/register/register.form.ts | 56 +++- src/app/register/register.modal.html | 17 +- src/app/register/register.modal.ts | 21 +- src/app/settings/settings.model.ts | 1 + src/app/settings/settings.page.html | 24 +- src/app/settings/settings.page.ts | 23 ++ src/app/settings/settings.service.ts | 30 +- src/app/shared/pages/base.page.ts | 53 +++- src/app/shared/pipes/address.pipes.ts | 1 + src/app/shared/services/platform.service.ts | 1 - .../services/startable-service.class.ts | 3 + .../services/{ => storage}/keyring-storage.ts | 3 +- .../services/storage/storage.service.ts | 19 +- ...{storage.interface.ts => storage.utils.ts} | 11 + src/app/transfer/transfer.page.html | 49 +-- src/app/transfer/transfer.page.scss | 4 + src/app/transfer/transfer.page.ts | 62 ++-- src/app/unlock/unlock.form.html | 18 +- src/app/unlock/unlock.form.ts | 16 +- src/app/unlock/unlock.modal.html | 52 ++++ src/app/unlock/unlock.modal.scss | 1 + src/app/unlock/unlock.modal.ts | 101 +++++++ src/app/unlock/unlock.module.ts | 27 +- src/app/wallet/account.model.ts | 4 + src/app/wallet/account.service.ts | 284 ++++++++++++++---- src/app/wallet/wallet.module.ts | 4 +- src/app/wallet/wallet.page.html | 16 +- src/app/wallet/wallet.page.ts | 12 +- src/assets/i18n/fr.json | 29 +- src/environments/environment.prod.ts | 3 +- src/environments/environment.ts | 4 +- src/global.scss | 41 ++- src/theme/_cesium.globals.scss | 3 +- src/theme/_cesium.scss | 9 + src/theme/_functions.scss | 7 + src/theme/_globals.scss | 4 + src/theme/_ionic.globals.scss | 6 + src/theme/_ionic.scss | 25 ++ src/theme/_mixins.scss | 105 +++++++ src/theme/_theme.scss | 12 + 48 files changed, 1096 insertions(+), 269 deletions(-) rename src/app/shared/services/{ => storage}/keyring-storage.ts (90%) rename src/app/shared/services/storage/{storage.interface.ts => storage.utils.ts} (61%) create mode 100644 src/app/unlock/unlock.modal.html create mode 100644 src/app/unlock/unlock.modal.scss create mode 100644 src/app/unlock/unlock.modal.ts create mode 100644 src/theme/_cesium.scss create mode 100644 src/theme/_functions.scss create mode 100644 src/theme/_globals.scss create mode 100644 src/theme/_ionic.scss create mode 100644 src/theme/_mixins.scss create mode 100644 src/theme/_theme.scss diff --git a/package.json b/package.json index 9da95a4..e2cbe84 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@capacitor/haptics": "4.0.1", "@capacitor/keyboard": "4.0.1", "@capacitor/status-bar": "4.0.1", + "@capacitor-community/sqlite": "^4.0.1", "@ionic/angular": "^6.2.4", "@ionic/storage-angular": "^3.0.6", "@ngx-translate/core": "^14.0.0", diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 90f9f03..033bc58 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core'; +import {Component} from '@angular/core'; import {PlatformService} from "./shared/services/platform.service"; import {environment} from "@environments/environment"; @@ -28,8 +28,11 @@ export class AppComponent { } async start() { + var now = Date.now(); console.info('[app] Starting...'); + await this.platform.start(); - console.info('[app] Starting [OK]'); + + console.info(`[app] Starting [OK] in ${Date.now()-now}ms`); } } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 15e6be2..ac23ccd 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -2,7 +2,7 @@ import {NgModule} from '@angular/core'; import {BrowserModule} from '@angular/platform-browser'; import {RouteReuseStrategy} from '@angular/router'; -import {IonicModule, IonicRouteStrategy, Platform} from '@ionic/angular'; +import {IonicModule, IonicRouteStrategy} from '@ionic/angular'; import {AppComponent} from './app.component'; import {AppRoutingModule} from './app-routing.module'; @@ -11,14 +11,15 @@ import {BrowserAnimationsModule} from "@angular/platform-browser/animations"; import {HttpClient, HttpClientModule} from "@angular/common/http"; import {TranslateLoader, TranslateModule} from '@ngx-translate/core'; import {TranslateHttpLoader} from '@ngx-translate/http-loader'; -import {IonicStorageModule, Storage} from '@ionic/storage-angular'; +import {IonicStorageModule} from '@ionic/storage-angular'; import {environment} from "@environments/environment"; import {AppSharedModule} from "@app/shared/shared.module"; import {APP_BASE_HREF} from "@angular/common"; import {JDENTICON_CONFIG} from "ngx-jdenticon"; import {APP_LOCALES} from "@app/settings/settings.model"; -import {APP_STORAGE} from "@app/shared/services/storage/storage.interface"; +import {APP_STORAGE} from "@app/shared/services/storage/storage.utils"; import {StorageService} from "@app/shared/services/storage/storage.service"; +import {Drivers} from "@ionic/storage"; export function createTranslateLoader(http: HttpClient) { return new TranslateHttpLoader(http, './assets/i18n/', '.json'); @@ -48,11 +49,11 @@ export function createTranslateLoader(http: HttpClient) { }), ], providers: [ - { provide: RouteReuseStrategy, useClass: IonicRouteStrategy }, - { provide: PlatformService, useClass: PlatformService }, - { provide: StorageService, useClass: StorageService, deps: [Platform, Storage] }, - { provide: APP_STORAGE, useExisting: StorageService }, + PlatformService, + StorageService, + {provide: RouteReuseStrategy, useClass: IonicRouteStrategy}, + {provide: APP_STORAGE, useExisting: StorageService}, {provide: APP_BASE_HREF, useValue: (environment.baseUrl || '/')}, { diff --git a/src/app/auth/auth.model.ts b/src/app/auth/auth.model.ts index f035217..76fea72 100644 --- a/src/app/auth/auth.model.ts +++ b/src/app/auth/auth.model.ts @@ -1,9 +1,14 @@ +import {AccountMeta} from "@app/wallet/account.model"; + export interface AuthData { + address?: string; password?: string; v1?: { salt: string; password: string; } + + meta?: AccountMeta; } diff --git a/src/app/home/home.page.html b/src/app/home/home.page.html index 2066605..0023158 100644 --- a/src/app/home/home.page.html +++ b/src/app/home/home.page.html @@ -45,18 +45,39 @@ </ng-template> </ion-card-title> </ion-card-header> - <ion-card-content> - - <!-- register --> - <ion-button id="open-register-modal" expand="block"> - <ion-label translate>LOGIN.CREATE_FREE_ACCOUNT</ion-label> - </ion-button> - - <!-- login --> - <p class="ion-padding-top" translate>LOGIN.HAVE_ACCOUNT_QUESTION</p> - <ion-button id="open-auth-modal" color="light" expand="block"> - <ion-label translate>COMMON.BTN_LOGIN</ion-label> - </ion-button> + <ion-card-content *ngIf="!loading" @fadeInAnimation> + + <ng-container *ngIf="isLogin; else noAccount"> + <!-- my account --> + <ion-button expand="block" [routerLink]="'/wallet'"> + <ion-icon name="person" slot="start"></ion-icon> + <ion-label translate>MENU.ACCOUNT</ion-label> + </ion-button> + + <!-- disconnect button --> + <p [class.cdk-visually-hidden]="mobile"> + <ion-text [innerHTML]="'HOME.NOT_YOUR_ACCOUNT_QUESTION' | translate: {pubkey: (defaultAccount|accountName) }"></ion-text> + <br/> + <ion-text> + <a href="#" (click)="logout($event)"> + <span translate>HOME.BTN_CHANGE_ACCOUNT</span> + </a> + </ion-text> + </p> + </ng-container> + + <ng-template #noAccount> + <!-- register --> + <ion-button expand="block" (click)="register($event)"> + <ion-label translate>LOGIN.CREATE_FREE_ACCOUNT</ion-label> + </ion-button> + + <!-- login --> + <p class="ion-padding-top" translate>LOGIN.HAVE_ACCOUNT_QUESTION</p> + <ion-button color="light" expand="block" (click)="login()"> + <ion-label translate>COMMON.BTN_LOGIN</ion-label> + </ion-button> + </ng-template> </ion-card-content> </ion-card> @@ -75,7 +96,6 @@ </ng-template> </ion-modal> - <ion-modal #registerModal trigger="open-register-modal" diff --git a/src/app/home/home.page.ts b/src/app/home/home.page.ts index 7ef430f..6d53756 100644 --- a/src/app/home/home.page.ts +++ b/src/app/home/home.page.ts @@ -1,30 +1,43 @@ -import {Component, Inject, Injector, OnInit} from '@angular/core'; +import {Component, Inject, Injector, OnInit, ViewChild} from '@angular/core'; import {SettingsService} from "@app/settings/settings.service"; import {APP_LOCALES, LocaleConfig, Settings} from "@app/settings/settings.model"; import {BasePage} from "@app/shared/pages/base.page"; import {NetworkService} from "@app/network/network.service"; import {AbbreviatePipe} from "@app/shared/pipes/string.pipes"; +import {AccountService} from "@app/wallet/account.service"; +import {Account} from "@app/wallet/account.model"; +import {fadeInAnimation} from "@app/shared/animations"; +import {AuthModal} from "@app/auth/auth.modal"; +import {RegisterModal} from "@app/register/register.modal"; +import {IonModal} from "@ionic/angular"; +import {Router} from "@angular/router"; @Component({ selector: 'app-home', templateUrl: './home.page.html', - styleUrls: ['./home.page.scss'] + styleUrls: ['./home.page.scss'], + animations: [fadeInAnimation] }) export class HomePage extends BasePage<Settings> implements OnInit { currency: string = null; + defaultAccount: Account = null; + get isLogin(): boolean { + return this.accountService.isLogin + } + + @ViewChild('authModal') authModal: IonModal; + @ViewChild('registerModal') registerModal: IonModal; + constructor( injector: Injector, - @Inject(APP_LOCALES) public locales: LocaleConfig[], - public networkService: NetworkService + public networkService: NetworkService, + public accountService: AccountService, + public router: Router, + @Inject(APP_LOCALES) public locales: LocaleConfig[] ) { super(injector, {name: 'home'}) - - } - - ngOnInit() { - super.ngOnInit(); } protected async ngOnLoad(): Promise<Settings> { @@ -33,6 +46,14 @@ export class HomePage extends BasePage<Settings> implements OnInit { this.currency = this.networkService.currency.name; + // Load account + await this.accountService.ready(); + if (this.accountService.isLogin) { + this.defaultAccount = await this.accountService.getDefault(); + } + else { + this.defaultAccount = null; + } return this.settings.clone(); } @@ -43,4 +64,25 @@ export class HomePage extends BasePage<Settings> implements OnInit { this.data.locale = locale; this.markForCheck(); } + + login(event) { + return this.authModal.present(); + } + + async register(event) { + await this.registerModal.present(); + + const {data} = await this.registerModal.onWillDismiss(); + + if (data?.address) { + this.defaultAccount = data; + setTimeout(() => this.router.navigateByUrl('/wallet/' + data.address)); + } + } + + logout(event) { + event?.preventDefault(); + this.accountService.forgetAll(); + this.defaultAccount = null; + } } diff --git a/src/app/network/network.service.ts b/src/app/network/network.service.ts index 58b343d..05b34be 100644 --- a/src/app/network/network.service.ts +++ b/src/app/network/network.service.ts @@ -58,7 +58,7 @@ export class NetworkService extends StartableService<ApiPromise> { const peer = this.selectRandomPeer(peers); const wsUri = Peers.getWsUri(peer); - console.info(`Connecting to peer {${wsUri}}...`) + console.info(`${this._logPrefix}Connecting to peer {${wsUri}}...`) // Extract all types from definitions - fast and dirty approach, flatted on 'types' // const types = Object.values(definitions).reduce((res: any, { types }): object => { @@ -75,10 +75,10 @@ export class NetworkService extends StartableService<ApiPromise> { // get the chain information const chainInfo = await api.registry.getChainProperties(); - this.debug('Connecting to chain: ', chainInfo.toHuman()); + console.debug(`${this._logPrefix}Connecting to chain: `, chainInfo.toHuman()); // Read the genesys block hash - console.info('Connected to Blockchain genesis: ' + api.genesisHash.toHex()); + console.info(`${this._logPrefix}Blockchain genesis: ` + api.genesisHash.toHex()); // Retrieve the chain name const chain = '' + (await api.rpc.system.chain()); @@ -89,7 +89,7 @@ export class NetworkService extends StartableService<ApiPromise> { // Retrieve the latest header const lastHeader = await api.rpc.chain.getHeader(); - console.info(`${this.currency.name} - last block #${lastHeader.number} has hash ${lastHeader.hash}`); + console.info(`${this._logPrefix}${this.currency.name} - last block #${lastHeader.number} has hash ${lastHeader.hash}`); return api; } diff --git a/src/app/register/register.form.html b/src/app/register/register.form.html index f27fd9e..803b832 100644 --- a/src/app/register/register.form.html +++ b/src/app/register/register.form.html @@ -34,22 +34,23 @@ <!-- explanation 1 --> <ion-slide class="ion-padding"> - <p>TODO i18n - Cesium fabrique votre portefeuille à partir d'une phrase de restauration. </p> + <p [innerHTML]="'ACCOUNT.NEW.STEP_1_HELP'|translate"></p> +<!-- <img src="assets/account-step-1.png">--> </ion-slide> <!-- explanation 2 --> <ion-slide class="ion-padding"> - <p>TODO i18n - Conservez cette phrase précieusement, car sans elle Cesium ne pourra pas resconstruire... </p> + <p [innerHTML]="'ACCOUNT.NEW.STEP_2_HELP'|translate"></p> </ion-slide> <!-- explanation 3 --> <ion-slide class="ion-padding"> - <p>TODO i18n - Dna sune blockchain, pas de procédure de récupration par mail. </p> + <p [innerHTML]="'ACCOUNT.NEW.STEP_3_HELP'|translate: {currency: currency?.name}"></p> </ion-slide> <!-- explanation 4 --> <ion-slide class="ion-padding"> - <p>TODO i18n - Il est temps de vous munir d'un papier... </p> + <p [innerHTML]="'ACCOUNT.NEW.STEP_4_HELP'|translate"></p> </ion-slide> <!-- generate mnemonic --> @@ -57,7 +58,7 @@ <ion-grid *ngIf="form|formGetValue:'words'; let words; else wordsSkeleton" class="words"> <ion-row> <ion-col size="12"> - <p>Cesium a généré votre phrase de restauration ! Tâchez ...</p> + <p [innerHTML]="'ACCOUNT.NEW.STEP_MNEMONIC_HELP'|translate"></p> </ion-col> </ion-row> @@ -91,15 +92,15 @@ <!-- check word --> <ion-slide> - <ion-list> + <ion-list *ngIf="form|formGetValue:'wordNumber' as wordNumber"> <ion-item lines="none"> <ion-text> - <p>TODO i18n - Avez-vous bien noté votre phrase de restauration ?</p> + <p [innerHTML]="'ACCOUNT.NEW.STEP_CHECK_WORD_HELP'|translate: {number: wordNumber}"></p> </ion-text> </ion-item> <ion-item> - <ion-label color="medium">{{'ACCOUNT.NEW.INPUT_WORD'|translate: {number: form|formGetValue:'wordNumber'} }}</ion-label> + <ion-label color="medium">{{'ACCOUNT.NEW.INPUT_WORD'|translate: {number: wordNumber} }}</ion-label> <ion-input (ionChange)="checkWord($event.detail.value)"></ion-input> <ion-icon slot="end" name="checkmark" *ngIf="slideState.canNext"></ion-icon> </ion-item> @@ -109,14 +110,14 @@ <!-- Explain code #1 --> <ion-slide> <ion-text> - <p>TODO i18n - Cesium va maintenant générer pour vous un code secret court, qui vous permettra...</p> + <p [innerHTML]="'ACCOUNT.NEW.STEP_CODE_1_HELP'|translate"></p> </ion-text> </ion-slide> <!-- Explain code #2 --> <ion-slide> <ion-text> - <p>TODO i18n - Ce code secret protège vos portefeuilles...</p> + <p [innerHTML]="'ACCOUNT.NEW.STEP_CODE_2_HELP'|translate"></p> </ion-text> </ion-slide> @@ -125,14 +126,13 @@ <ion-grid> <ion-row> - <ion-col> - <ion-text>TODO i18n - Et voila votre code</ion-text> + <ion-col size="12"> + <p [innerHTML]="'ACCOUNT.NEW.STEP_CODE_3_HELP'|translate"></p> + </ion-col> + <ion-col size="12"> <ion-label color="medium" translate>ACCOUNT.NEW.YOUR_SECRET_CODE</ion-label> </ion-col> - </ion-row> - - <ion-row class="ion-padding-top"> - <ion-col> + <ion-col size="12" class="ion-padding-top"> <ion-item> <ion-text class="ion-text-center" style="width: 100%"> <h2>{{form|formGetValue: 'code'}}</h2> @@ -156,15 +156,43 @@ <!-- check code --> <ion-slide> <app-unlock-form [control]="form|formGetControl: 'codeConfirmation'" - (change)="checkCodeConfirmation($event)"></app-unlock-form> + helpMessage="ACCOUNT.NEW.STEP_CHECK_CODE_HELP" + (change)="checkCodeConfirmation()"> + + </app-unlock-form> </ion-slide> <!-- congratulation ! --> <ion-slide> + <ion-row> + <ion-col size="12"> + <p [innerHTML]="'ACCOUNT.NEW.STEP_CONGRATULATION_1_HELP'|translate"></p> + </ion-col> + <ion-col size="12" class="ion-padding-top"> + <ion-item> + <ion-text class="ion-text-center" style="width: 100%"> + {{form|formGetValue: 'address'}} + </ion-text> + </ion-item> + </ion-col> + <ion-col size="12" class="ion-padding-top"> + <p [innerHTML]="'ACCOUNT.NEW.STEP_CONGRATULATION_2_HELP'|translate"></p> + </ion-col> + <ion-col size="12" class="ion-padding-top"> + <ion-item> + <ion-text class="ion-text-center" style="width: 100%"> + <h2>{{form|formGetValue: 'address'|addressFormat}}</h2> + </ion-text> + </ion-item> + </ion-col> + </ion-row> + + <ng-content select="[last]"></ng-content> </ion-slide> </ion-slides> + <ng-template #wordsSkeleton> <ion-grid> <ion-row> diff --git a/src/app/register/register.form.ts b/src/app/register/register.form.ts index 4d4fa85..c90f702 100644 --- a/src/app/register/register.form.ts +++ b/src/app/register/register.form.ts @@ -24,7 +24,8 @@ export const REGISTER_FORM_SLIDES = { MNEMONIC: 5, ASK_WORD: 6, CODE: 9, - CODE_CONFIRMATION: 10 + CODE_CONFIRMATION: 10, + CONGRATULATION: 11 } @Component({ @@ -34,6 +35,7 @@ export const REGISTER_FORM_SLIDES = { }) export class RegisterForm extends AppForm<RegisterData> implements OnInit { + private readonly _isDevelopment: boolean; slideOpts = { initialSlide: 0, speed: 400, @@ -68,19 +70,24 @@ export class RegisterForm extends AppForm<RegisterData> implements OnInit { wordNumber: new FormControl(null, Validators.required), code: new FormControl(null, Validators.required), codeConfirmation: new FormControl(null, Validators.compose([Validators.required, this.equalsValidator('code')])), - name: new FormControl(null) + name: new FormControl(null), + address: new FormControl(null) })); + + this.debug = !environment.production; + this._isDevelopment = !environment.production; } ngOnInit() { // For DEV only ------------------------ - if (!environment.production) { + if (this._isDevelopment) { this.form.setValue({ words: 'search average amateur muffin inspire lake resist width intact viable stone barrel'.split(' '), wordNumber: 1, code: 'AAAAA', codeConfirmation: null, - name: 'Nouveau portefeuille' + name: 'Nouveau portefeuille', + address: null }); } } @@ -103,6 +110,7 @@ export class RegisterForm extends AppForm<RegisterData> implements OnInit { } async slideNext() { + console.log("slideNext from slide #" + this.slideState.index); return this.slides.slideNext() .then(() => this.updateState()); } @@ -162,10 +170,15 @@ export class RegisterForm extends AppForm<RegisterData> implements OnInit { this.slideState.isEnd = await this.slides.isEnd(); this.markForCheck(); + console.debug('[register-form] Slide #' + this.slideState.index); + switch (this.slideState.index) { case REGISTER_FORM_SLIDES.MNEMONIC: if (!this.form.get('words').valid) { - this.generatePhrase(); + await this.generatePhrase(); + } + else { + this.slideState.canNext = false; } break; case REGISTER_FORM_SLIDES.ASK_WORD: @@ -175,13 +188,18 @@ export class RegisterForm extends AppForm<RegisterData> implements OnInit { this.generateCode(); break; case REGISTER_FORM_SLIDES.CODE_CONFIRMATION: - this.slideState.canNext = false; + this.checkCodeConfirmation(); break; + case REGISTER_FORM_SLIDES.CONGRATULATION: + await this.generateAccount(); + break; + default: + this.slideState.canNext = true; } } protected async generatePhrase() { - if (!environment.production) return; + if (this._isDevelopment) return; // Keep existing mnemonic // Clear previous phrase this.form.get('words').reset(null); @@ -189,11 +207,11 @@ export class RegisterForm extends AppForm<RegisterData> implements OnInit { this.markForCheck(); setTimeout(async () => { - const mnemonic = await this.accountService.generateNew(); + const mnemonic = await this.accountService.generateMnemonic(); this.form.patchValue({ words: mnemonic.split(' ') }); - }); + }, 250 * Math.random()); } protected toggleCanNext() { @@ -237,9 +255,27 @@ export class RegisterForm extends AppForm<RegisterData> implements OnInit { this.markForCheck(); } - checkCodeConfirmation(code: string) { + checkCodeConfirmation() { + if (this.slideState.index !== REGISTER_FORM_SLIDES.CODE_CONFIRMATION) return; + const code = this.form.get('codeConfirmation').value; const expectedCode = this.form.get('code').value; this.slideState.canNext = expectedCode === code; this.markForCheck(); } + + async generateAccount() { + if (this.slideState.index !== REGISTER_FORM_SLIDES.CONGRATULATION) return; // Skip + + this.slideState.canNext = false; + this.markAsLoading(); + + setTimeout(async () => { + const data = this.value; + const account = await this.accountService.createAddress(data); + this.form.get('address').setValue(account.address); + + this.slideState.canNext = true; + this.markAsLoaded(); + }, 250); + } } diff --git a/src/app/register/register.modal.html b/src/app/register/register.modal.html index 7baedfd..645a8b1 100644 --- a/src/app/register/register.modal.html +++ b/src/app/register/register.modal.html @@ -30,7 +30,19 @@ <app-register-form (onSubmit)="doSubmit()" (onCancel)="cancel()" [class]="mobile ? '': 'has-footer'" #form> + + + <ion-row *ngIf="form.debug" codeConfirmation> + <ion-text color="primary" class="ion-padding"> + <small>loading: {{loading}}<br/> + canNext: {{form.canNext()}}<br/> + isEnd: {{form.isEnd()}}<br/> + </small> + </ion-text> + </ion-row> </app-register-form> + + <!-- buttons --> <ion-toolbar> @@ -62,12 +74,13 @@ <ion-button *ngIf="form.isEnd()" (click)="doSubmit()" fill="solid" [disabled]="loading || !form.canNext()" color="tertiary"> - <span translate>COMMON.BTN_SEND</span> - <ion-icon slot="end" name="send"></ion-icon> + <span translate>COMMON.BTN_SAVE</span> + <ion-icon slot="end" name="save"></ion-icon> </ion-button> </ion-col> </ion-row> + </ion-toolbar> </ion-content> diff --git a/src/app/register/register.modal.ts b/src/app/register/register.modal.ts index 7ae561d..963efb4 100644 --- a/src/app/register/register.modal.ts +++ b/src/app/register/register.modal.ts @@ -41,9 +41,9 @@ export class RegisterModal implements OnInit{ // DEV if (!environment.production) { - setTimeout(() => { - this.form.slideTo(REGISTER_FORM_SLIDES.MNEMONIC); - }); + // setTimeout(() => { + // this.form.slideTo(REGISTER_FORM_SLIDES.MNEMONIC); + // }); } } @@ -54,6 +54,7 @@ export class RegisterModal implements OnInit{ } async doSubmit(event?: any) { + console.debug('[register-modal] Submit...'); if (this.form.disabled) return; // Skip if (!this.form.valid) { @@ -68,18 +69,24 @@ export class RegisterModal implements OnInit{ const data = this.form.value; this.form.disable(); + this.form.markAsLoading(); try { - console.debug('[register] Sending registration to server...', data); + console.debug('[register] Saving new account...'); - await this.accountService.register(data); + const registered = await this.accountService.register(data); - console.debug('[register] Account registered!'); - await this.viewCtrl.dismiss(); + const address = registered && this.form.form.get('address').value; + if (address) { + console.debug('[register] Account registered, with address: ' + address); + const account = await this.accountService.getByAddress(address); + await this.viewCtrl.dismiss(account); + } } catch (err) { this.form.error = err && err.message || err; this.form.enable(); + this.form.markAsLoaded(); } } } diff --git a/src/app/settings/settings.model.ts b/src/app/settings/settings.model.ts index 38817ac..75c4590 100644 --- a/src/app/settings/settings.model.ts +++ b/src/app/settings/settings.model.ts @@ -16,5 +16,6 @@ export declare interface Settings { locale?: string; mobile?: boolean; properties?: PropertiesMap; + unAuthDelayMs?: number; } diff --git a/src/app/settings/settings.page.html b/src/app/settings/settings.page.html index ec15171..7ce703b 100644 --- a/src/app/settings/settings.page.html +++ b/src/app/settings/settings.page.html @@ -20,7 +20,10 @@ <ion-item> <ion-icon slot="start" name="language"></ion-icon> <ion-label color="dark" translate>COMMON.LANGUAGE</ion-label> - <ion-select [(ngModel)]="data.locale"> + <ion-select [(ngModel)]="data.locale" + [interface]="mobile ? 'action-sheet' : 'popover'" + [okText]="'COMMON.BTN_OK'|translate" + [cancelText]="'COMMON.BTN_CANCEL'|translate"> <ion-select-option *ngFor="let locale of locales" [value]="locale.key"> {{locale.value}} </ion-select-option> @@ -38,9 +41,26 @@ </ion-input> <ion-button slot="end" (click)="selectPeer()"> - <ion-icon slot="icon-only" name=""></ion-icon> + <ion-label>...</ion-label> </ion-button> </ion-item> + + <ion-item-divider translate>SETTINGS.AUTHENTICATION_SETTINGS</ion-item-divider> + + <ion-item> + + <ion-icon slot="start" name="lock-open"></ion-icon> + <ion-label color="dark" translate>SETTINGS.KEEP_AUTH</ion-label> + + <ion-select [(ngModel)]="data.unAuthDelayMs" + [interface]="mobile ? 'action-sheet' : 'popover'" + [okText]="'COMMON.BTN_OK'|translate" + [cancelText]="'COMMON.BTN_CANCEL'|translate"> + <ion-select-option *ngFor="let item of unauthOptions" [value]="item.value"> + {{item.label|translate:{value: item.labelParam} }} + </ion-select-option> + </ion-select> + </ion-item> </ion-list> <div class="ion-text-center"> diff --git a/src/app/settings/settings.page.ts b/src/app/settings/settings.page.ts index 2d0821e..2b6f43d 100644 --- a/src/app/settings/settings.page.ts +++ b/src/app/settings/settings.page.ts @@ -10,6 +10,29 @@ import {BasePage} from "@app/shared/pages/base.page"; }) export class SettingsPage extends BasePage<Settings> implements OnInit { + unauthOptions = [ + { + label: 'SETTINGS.KEEP_AUTH_OPTION.SECONDS', + labelParam: 10, + value: 10_000 + }, + { + label: 'SETTINGS.KEEP_AUTH_OPTION.SECONDS', + labelParam: 30, + value: 30_000 + }, + { + label: 'SETTINGS.KEEP_AUTH_OPTION.MINUTE', + labelParam: 1, + value: 60_000 + }, + { + label: 'SETTINGS.KEEP_AUTH_OPTION.MINUTES', + labelParam: 15, + value: 15*60_000 + } + ]; + constructor( injector: Injector, @Inject(APP_LOCALES) public locales: LocaleConfig[] diff --git a/src/app/settings/settings.service.ts b/src/app/settings/settings.service.ts index 0b31eb4..7c8e40f 100644 --- a/src/app/settings/settings.service.ts +++ b/src/app/settings/settings.service.ts @@ -1,9 +1,12 @@ -import {Injectable} from "@angular/core"; +import {Inject, Injectable, Optional} from "@angular/core"; import {Settings} from "./settings.model"; import {environment} from "@environments/environment"; import {StartableService} from "@app/shared/services/startable-service.class"; import {Platform} from "@ionic/angular"; import {Subject} from "rxjs"; +import {APP_STORAGE, IStorage} from "@app/shared/services/storage/storage.utils"; + +const SETTINGS_STORAGE_KEY = 'settings'; @Injectable({providedIn: 'root'}) export class SettingsService extends StartableService<Settings> { @@ -16,12 +19,18 @@ export class SettingsService extends StartableService<Settings> { return this._mobile; } + get data(): Settings { + return this._data; + } + constructor( - protected ionicPlatform: Platform + protected ionicPlatform: Platform, + @Inject(APP_STORAGE) @Optional() protected storage?: IStorage ) { super(ionicPlatform, { name: 'settings-service' - }) + }); + } protected async ngOnStart(): Promise<Settings> { @@ -43,10 +52,14 @@ export class SettingsService extends StartableService<Settings> { } async restoreLocally(): Promise<Settings> { + + const savedData = await this.storage.get(SETTINGS_STORAGE_KEY); const data = <Settings>{ preferredPeers: !environment.production && environment.dev?.peer ? [environment.dev.peer] - : [...environment.defaultPeers] + : [...environment.defaultPeers], + unAuthDelayMs: 15 * 60_000, // 15min + ...savedData }; return data; } @@ -58,9 +71,16 @@ export class SettingsService extends StartableService<Settings> { ...data }; this.changes.next(this._data); + + // Saving changes + setTimeout(() => this.saveLocally(), 250); } async saveLocally() { - // TODO + if (!this.storage) return; // Skip, no storage + + console.info('[settings] Saving settings to the storage...'); + const data = this.clone(); + await this.storage?.set('settings', data); } } diff --git a/src/app/shared/pages/base.page.ts b/src/app/shared/pages/base.page.ts index 31d334b..7587ed0 100644 --- a/src/app/shared/pages/base.page.ts +++ b/src/app/shared/pages/base.page.ts @@ -1,4 +1,4 @@ -import {ChangeDetectorRef, Directive, inject, Injector, OnInit} from '@angular/core'; +import {ChangeDetectorRef, Directive, inject, Injector, OnDestroy, OnInit} from '@angular/core'; import {ActivatedRoute} from '@angular/router'; import {SettingsService} from "@app/settings/settings.service"; import {changeCaseToUnderscore, isNotNilOrBlank} from "@app/shared/functions"; @@ -7,18 +7,30 @@ import {waitIdle} from "@app/shared/forms"; import {WaitForOptions} from "@app/shared/observables"; import {ToastController, ToastOptions} from "@ionic/angular"; import {TranslateService} from "@ngx-translate/core"; +import {Subscription} from "rxjs"; + +export interface BasePageOptions { + name: string; + loadDueTime: number; +} @Directive() -export abstract class BasePage<S> implements OnInit { +export abstract class BasePage< + S, + O extends BasePageOptions = BasePageOptions + > + implements OnInit, OnDestroy { private _cd: ChangeDetectorRef; + private _subscription: Subscription; protected translate: TranslateService; protected settings: SettingsService; - protected activatedRoute: ActivatedRoute; + protected readonly activatedRoute: ActivatedRoute; protected toastController: ToastController; protected readonly _debug = !environment.production; protected readonly _logPrefix: string; + protected readonly _options: O; mobile: boolean = null; error: string = null; @@ -30,9 +42,8 @@ export abstract class BasePage<S> implements OnInit { } protected constructor( - injector: Injector, options: { - name?: string - } + injector: Injector, + options?: Partial<O> ) { this._cd = injector.get(ChangeDetectorRef); this.settings = injector.get(SettingsService); @@ -40,12 +51,24 @@ export abstract class BasePage<S> implements OnInit { this.activatedRoute = injector.get(ActivatedRoute); this.toastController = injector.get(ToastController); this.mobile = this.settings.mobile; - this._logPrefix = options?.name ? `[${options.name}] ` : `[${changeCaseToUnderscore(this.constructor.name).replace(/_/g, '-')}]`; + this._options = <O>{ + name: options?.name || changeCaseToUnderscore(this.constructor.name).replace(/_/g, '-'), + loadDueTime: 0, + ...options + }; + this._logPrefix = `[${this._options.name}] `; } ngOnInit() { - this.load(); + + // Load data + setTimeout(() => this.load(), this._options.loadDueTime || 0); + } + + ngOnDestroy() { + console.debug(`${this._logPrefix}Destroy`); + this._subscription?.unsubscribe(); } protected async load() { @@ -112,6 +135,11 @@ export abstract class BasePage<S> implements OnInit { else console.debug(this._logPrefix + msg) } + protected info(msg, ...params: any[]) { + if (params) console.info(this._logPrefix + msg, params); + else console.info(this._logPrefix + msg) + } + protected log(msg, ...params: any[]) { if (!this._debug) return; if (params) console.log(this._logPrefix + msg, params); @@ -127,4 +155,13 @@ export abstract class BasePage<S> implements OnInit { }); return toast.present(); } + + protected registerSubscription(sub: Subscription) { + if (!this._subscription) this._subscription = new Subscription(); + this._subscription.add(sub); + } + + protected unregisterSubscription(sub: Subscription) { + this._subscription?.remove(sub); + } } diff --git a/src/app/shared/pipes/address.pipes.ts b/src/app/shared/pipes/address.pipes.ts index cce70c1..56c4a8e 100644 --- a/src/app/shared/pipes/address.pipes.ts +++ b/src/app/shared/pipes/address.pipes.ts @@ -6,6 +6,7 @@ import {Pipe, PipeTransform} from '@angular/core'; export class AddressFormatPipe implements PipeTransform { transform(value: string, withChecksum?: boolean ): string { + if (!value) return ''; if (value.length < 12) return '?'; return value.substring(0,6) + '...' + value.substring(value.length - 6); } diff --git a/src/app/shared/services/platform.service.ts b/src/app/shared/services/platform.service.ts index f861f7b..4d38cf0 100644 --- a/src/app/shared/services/platform.service.ts +++ b/src/app/shared/services/platform.service.ts @@ -46,7 +46,6 @@ export class PlatformService extends StartableService { this._mobile = this.mobile; this._touchUi = this.touchUi; - // Configure translation await this.configureTranslate(); diff --git a/src/app/shared/services/startable-service.class.ts b/src/app/shared/services/startable-service.class.ts index 4ece06e..9fcbe65 100644 --- a/src/app/shared/services/startable-service.class.ts +++ b/src/app/shared/services/startable-service.class.ts @@ -2,6 +2,7 @@ import {Optional} from '@angular/core'; import {Subject} from 'rxjs'; import {waitFor} from '../observables'; import {BaseService, IBaseServiceOptions} from "@app/shared/services/base-service.class"; +import {environment} from "@environments/environment"; export interface IStartableService<T = any> { started: boolean; @@ -21,6 +22,7 @@ export abstract class StartableService<T = any, O extends IStartableServiceOptio protected _startByReadyFunction = true; // should start when calling ready() ? protected _data: T = null; + protected _debug: boolean = false; private _started = false; private _startPromise: Promise<T> = null; @@ -34,6 +36,7 @@ export abstract class StartableService<T = any, O extends IStartableServiceOptio this._startPrerequisite = prerequisiteService ? () => prerequisiteService.ready() : () => Promise.resolve(); + this._debug = !environment.production; } start(): Promise<T> { diff --git a/src/app/shared/services/keyring-storage.ts b/src/app/shared/services/storage/keyring-storage.ts similarity index 90% rename from src/app/shared/services/keyring-storage.ts rename to src/app/shared/services/storage/keyring-storage.ts index 4e8f796..5fb913c 100644 --- a/src/app/shared/services/keyring-storage.ts +++ b/src/app/shared/services/storage/keyring-storage.ts @@ -1,6 +1,6 @@ import {KeyringJson, KeyringStore} from "@polkadot/ui-keyring/types"; import {Directive} from '@angular/core'; -import {IStorage} from "@app/shared/services/storage/storage.interface"; +import {IStorage} from "@app/shared/services/storage/storage.utils"; // @dynamic @Directive() @@ -29,6 +29,7 @@ export class KeyringStorage implements KeyringStore { all(cb: (key: string, value: KeyringJson) => void) { this.storage.forEach((value, key, counter) => { + console.debug("Reading key=" + key, value); if (key.startsWith(this.storagePrefix)) { const shortKey = key.substring(this.storagePrefix.length); cb(shortKey, value as KeyringJson); diff --git a/src/app/shared/services/storage/storage.service.ts b/src/app/shared/services/storage/storage.service.ts index 4a8d98f..9b2c07f 100644 --- a/src/app/shared/services/storage/storage.service.ts +++ b/src/app/shared/services/storage/storage.service.ts @@ -1,7 +1,7 @@ import {Injectable} from '@angular/core'; import {Storage} from '@ionic/storage-angular'; import {StartableService} from "@app/shared/services/startable-service.class"; -import {IStorage} from "@app/shared/services/storage/storage.interface"; +import {IStorage} from "@app/shared/services/storage/storage.utils"; import {Platform} from '@ionic/angular'; @Injectable({ @@ -20,18 +20,22 @@ export class StorageService extends StartableService<Storage> this.start(); } - protected async ngOnStart(): Promise<Storage> { await this.platform.ready(); - return this.storage.create(); + const storage = await this.storage.create(); + console.info(`[storage-service] Started using driver=${storage?.driver}`); + return storage; } async set(key: string, value: any) { + console.debug(`[storage-service] Set ${key} = `, value); + if (!this.started) await this.ready(); return this._data.set(key, value); } async get(key: string): Promise<any> { + console.debug(`[storage-service] Get ${key} ...`); if (!this.started) await this.ready(); return this._data.get(key); } @@ -42,8 +46,11 @@ export class StorageService extends StartableService<Storage> } async keys(): Promise<string[]> { + console.debug(`[storage-service] Get keys...`); if (!this.started) await this.ready(); - return this._data.keys(); + const keys = await this._data.keys(); + console.debug(`[storage-service] ${keys.length} keys found: `, keys); + return keys; } async clear() { @@ -53,6 +60,8 @@ export class StorageService extends StartableService<Storage> async forEach(iteratorCallback: (value: any, key: string, iterationNumber: Number) => any): Promise<void> { if (!this.started) await this.ready(); - return this._data.forEach(iteratorCallback); + return this._data.forEach((value, key, iterationNumber) => { + iteratorCallback(value, key, iterationNumber); + }); } } diff --git a/src/app/shared/services/storage/storage.interface.ts b/src/app/shared/services/storage/storage.utils.ts similarity index 61% rename from src/app/shared/services/storage/storage.interface.ts rename to src/app/shared/services/storage/storage.utils.ts index 9d3e4fa..085ab55 100644 --- a/src/app/shared/services/storage/storage.interface.ts +++ b/src/app/shared/services/storage/storage.utils.ts @@ -1,4 +1,7 @@ import {InjectionToken} from "@angular/core"; +import {Drivers} from "@ionic/storage"; +import * as LocalForage from "localforage"; + export interface IStorage<T = any> { readonly driver: string; @@ -11,4 +14,12 @@ export interface IStorage<T = any> { forEach(iteratorCallback: (value: any, key: string, iterationNumber: Number) => any): Promise<void>; } +export const StorageDrivers = { + //SQLLite: CordovaSQLiteDriver._driver, + SecureStorage: Drivers.SecureStorage, + WebSQL: LocalForage.WEBSQL, + IndexedDB: Drivers.IndexedDB, + LocalStorage: Drivers.LocalStorage +}; + export const APP_STORAGE = new InjectionToken<IStorage>('Storage'); diff --git a/src/app/transfer/transfer.page.html b/src/app/transfer/transfer.page.html index 3eaeaed..93d5530 100644 --- a/src/app/transfer/transfer.page.html +++ b/src/app/transfer/transfer.page.html @@ -24,29 +24,44 @@ <!-- TO --> <ion-item> - <ion-label color="medium" slot="start" translate>TRANSFER.TO</ion-label> - <ion-input [(ngModel)]="recipient.address" (ionFocus)="modal.present()"> + <ion-label color="medium" translate>TRANSFER.TO</ion-label> + <ion-input *ngIf="data|async|isNotEmptyArray; else inputSkeleton" + [(ngModel)]="recipient.address" (ionFocus)="modal.present()"> </ion-input> - <ion-button slot="end" fill="clear" id="open-modal-trigger" [title]="'COMMON.BTN_SEARCH'|translate"> + <ion-button slot="end" fill="clear" id="open-modal-trigger" + [disabled]="loading" + [title]="'COMMON.BTN_SEARCH'|translate"> <ion-icon slot="icon-only" name="search"></ion-icon> </ion-button> </ion-item> <!-- FROM --> - <ion-item > + <ion-item tappable> <ion-label color="medium" translate>TRANSFER.FROM</ion-label> - <ion-select *ngIf="data|async; let accounts; else inputSkeleton" - [(ngModel)]="issuer.address" [interface]="mobile ? 'action-sheet' : 'popover'" - [okText]="'COMMON.BTN_OK'|translate" - [cancelText]="'COMMON.BTN_CANCEL'|translate"> - <ion-select-option *ngFor="let account of accounts" - [value]="account.address" - [disabled]="!(account|balance)"> - {{account|accountName}} - <ng-container *ngIf="account|balance; let balance">({{balance|amountFormat}})</ng-container> - </ion-select-option> - </ion-select> - <ion-badge *ngIf="issuer?.address" slot="end">{{issuer|balance|amountFormat}}</ion-badge> + <ng-container *ngIf="loading; else select"> + <ng-container *ngTemplateOutlet="inputSkeleton"></ng-container> + </ng-container> + <ng-template #select> + <ng-container *ngIf="data|async; let accounts; else inputSkeleton"> + <ion-select #ionSelect + [compareWith]="compareWith" + [selectedText]="issuer|accountName" + [(ngModel)]="issuer" + [interface]="mobile ? 'action-sheet' : 'popover'" + [interfaceOptions]="mobile ? actionSheetOptions : popoverOptions" + [okText]="'COMMON.BTN_OK'|translate" + [cancelText]="'COMMON.BTN_CANCEL'|translate"> + <ion-select-option *ngFor="let account of accounts" + [value]="account" + [disabled]="!(account|balance)"> + + {{account|accountName}} + <span *ngIf="account|balance; let balance">({{balance|amountFormat}})</span> + </ion-select-option> + </ion-select> + <ion-badge *ngIf="issuer" slot="end">{{issuer|balance|amountFormat}}</ion-badge> + </ng-container> + </ng-template> </ion-item> <ion-item> @@ -113,5 +128,5 @@ </ion-footer> <ng-template #inputSkeleton> - <ion-skeleton-text [animated]="true" style="width: 60%"></ion-skeleton-text> + <ion-skeleton-text [animated]="true" ></ion-skeleton-text> </ng-template> diff --git a/src/app/transfer/transfer.page.scss b/src/app/transfer/transfer.page.scss index c5fe043..03b00c3 100644 --- a/src/app/transfer/transfer.page.scss +++ b/src/app/transfer/transfer.page.scss @@ -8,3 +8,7 @@ ion-menu-button { .balance { color: var(--ion-color-base); } +ion-select.hidden { + display: none; + visibility: hidden; +} diff --git a/src/app/transfer/transfer.page.ts b/src/app/transfer/transfer.page.ts index bf11748..9dc265f 100644 --- a/src/app/transfer/transfer.page.ts +++ b/src/app/transfer/transfer.page.ts @@ -9,21 +9,12 @@ import { } from '@angular/core'; import {AccountService} from "../wallet/account.service"; import {BasePage} from "@app/shared/pages/base.page"; -import {Account, AccountUtils} from "@app/wallet/account.model"; -import {IonModal} from "@ionic/angular"; -import { - BehaviorSubject, - combineAll, - combineLatestAll, - concat, - concatAll, - from, - Observable, - Subject, - switchMap, - zip -} from "rxjs"; -import {isNotNil} from "@app/shared/functions"; +import {Account} from "@app/wallet/account.model"; +import {ActionSheetOptions, IonModal, PopoverOptions} from "@ionic/angular"; +import {BehaviorSubject, firstValueFrom, Observable} from "rxjs"; +import {isNotEmptyArray} from "@app/shared/functions"; +import {filter} from "rxjs/operators"; +import {WotLookupPage} from "@app/wot/wot-lookup.page"; @Component({ selector: 'app-transfer', @@ -34,10 +25,18 @@ import {isNotNil} from "@app/shared/functions"; export class TransferPage extends BasePage<Observable<Account[]>> implements OnInit, OnDestroy { showComment: boolean; - issuer: Partial<Account> = {}; - recipient: Partial<Account> = {}; + issuer: Account = null; + recipient: Account = {address: null, meta: null}; amount: number; + protected actionSheetOptions: Partial<ActionSheetOptions> = { + cssClass: 'select-account-action-sheet' + }; + protected popoverOptions: Partial<PopoverOptions> = { + cssClass: 'select-account-popover', + reference: 'event' + }; + @ViewChild('modal') modal: IonModal; get balance(): number { @@ -51,7 +50,7 @@ export class TransferPage extends BasePage<Observable<Account[]>> implements OnI protected accountService: AccountService, protected cd: ChangeDetectorRef ) { - super(injector, {name: 'transfer'}); + super(injector, {name: 'transfer', loadDueTime: 250}); } ngOnInit() { @@ -59,6 +58,7 @@ export class TransferPage extends BasePage<Observable<Account[]>> implements OnI } ngOnDestroy() { + super.ngOnDestroy(); if (this.modal.isOpen) { this.modal.dismiss(); } @@ -67,17 +67,29 @@ export class TransferPage extends BasePage<Observable<Account[]>> implements OnI protected async ngOnLoad(): Promise<Observable<Account[]>> { await this.accountService.ready(); - return this.accountService.watchAll({positiveBalanceFirst: true}); + const subject = new BehaviorSubject<Account[]>(null); + this.registerSubscription( + this.accountService.watchAll({positiveBalanceFirst: true}) + .pipe(filter(isNotEmptyArray)) + .subscribe((value) => subject.next(value)) + ); + + const accounts = await firstValueFrom(subject); + + // Only one account: select it + if (accounts?.length === 1) { + this.issuer = accounts[0]; + } + + return subject; } setRecipient(recipient: string|Account) { if (typeof recipient === 'object') { - this.recipient.address = recipient.address; - this.recipient.meta = recipient.meta; + this.recipient = recipient; } else { - this.recipient.address = recipient; - this.recipient.meta = null; + this.recipient = {address: recipient, meta: null}; } this.markForCheck(); } @@ -101,4 +113,8 @@ export class TransferPage extends BasePage<Observable<Account[]>> implements OnI this.setError(err); } } + + compareWith(a1: Account, a2: Account) { + return a1.address === a2.address; + } } diff --git a/src/app/unlock/unlock.form.html b/src/app/unlock/unlock.form.html index ed5a552..9b8dbee 100644 --- a/src/app/unlock/unlock.form.html +++ b/src/app/unlock/unlock.form.html @@ -1,21 +1,23 @@ <form [formGroup]="form"> <ion-list> <ion-item lines="none"> - <ion-text> - <p>TODO i18n - Pour vous authentifier, veuillez <b>composer votre code secret</b> :</p> - </ion-text> + <p [innerHTML]="helpMessage|translate"></p> + </ion-item> <ion-item> <ion-label color="medium">{{'AUTH.PASSPHRASE'|translate }}</ion-label> - <ion-input formControlName="code" [maxlength]="maxLength" [minlength]="minLength" required - (ionInput)="onChange($event)"></ion-input> + <ion-input [formControl]="control" + [maxlength]="maxLength" + [minlength]="minLength" + (ionChange)="onChange($event)" + required></ion-input> <ion-icon slot="end" name="checkmark" *ngIf="$valid|async"></ion-icon> </ion-item> - <ion-item *ngIf="debug" lines="none"> - <ion-text> - {{form|formGetValue:'code'}} + <ion-item *ngIf="debug && expectedCode" lines="none"> + <ion-text color="primary"> + <small>expected: {{expectedCode}}</small> </ion-text> </ion-item> </ion-list> diff --git a/src/app/unlock/unlock.form.ts b/src/app/unlock/unlock.form.ts index 4dcee5f..0865da5 100644 --- a/src/app/unlock/unlock.form.ts +++ b/src/app/unlock/unlock.form.ts @@ -40,6 +40,7 @@ export class UnlockForm extends AppForm<string> implements OnInit { @Input('class') classList: string = null; + @Input() helpMessage = 'AUTH.PASSPHRASE_HELP'; @Input() expectedCode: string = null; @Input() minLength: number = 5; @Input() maxLength: number = 5; @@ -62,7 +63,7 @@ export class UnlockForm extends AppForm<string> implements OnInit { } ngOnInit() { - if (!this.control) { + if (!this.control && this.formGroupDir && this.controlName) { const formControlName = (this.formGroupDir.directives || []).find(d => this.controlName && d.name === this.controlName); this.control = formControlName && formControlName.control; if (this.formGroupDir && this.control) { @@ -75,7 +76,7 @@ export class UnlockForm extends AppForm<string> implements OnInit { } else { this.setForm(this.formBuilder.group({ - code: new FormControl(this.createValidator()) + code: new FormControl(null, this.createValidator()) })); this.control = this.form.get('code') as FormControl; } @@ -94,21 +95,20 @@ export class UnlockForm extends AppForm<string> implements OnInit { } - get value(): RegisterData { - const json = this.form.value; - return json.code; + get value(): string { + return this.control.value; } get valid(): boolean { - return this.form.valid; + return this.control.valid; } cancel() { this.onCancel.emit(); } - onChange(event: UIEvent, value?: string) { - value = this.control?.value; + onChange(event: CustomEvent<{ value: string; }>) { + let value = event.detail?.value; value = value && value.toUpperCase() || null; if (value && value.length > this.maxLength) { event.preventDefault(); diff --git a/src/app/unlock/unlock.modal.html b/src/app/unlock/unlock.modal.html new file mode 100644 index 0000000..2f0537e --- /dev/null +++ b/src/app/unlock/unlock.modal.html @@ -0,0 +1,52 @@ +<ion-header> + <ion-toolbar [color]="auth?'danger': 'primary'"> + <ion-buttons slot="start"> + <ion-button (click)="cancel()" *ngIf="mobile"> + <ion-icon slot="icon-only" name="arrow-back"></ion-icon> + </ion-button> + </ion-buttons> + + <ion-title [innerHTML]="auth?'AUTH.TITLE': 'LOGIN.TITLE'|translate"> + </ion-title> + + <ion-buttons slot="end"> + <ion-spinner *ngIf="loading"></ion-spinner> + + <ion-button + (click)="doSubmit()" *ngIf="!loading && mobile"> + <ion-icon slot="icon-only" name="checkmark"></ion-icon> + </ion-button> + </ion-buttons> + </ion-toolbar> +</ion-header> + +<ion-content style="height: 100%"> + <app-unlock-form #form + (onSubmit)="doSubmit($event)" + (onCancel)="cancel()"> + </app-unlock-form> + + <ion-toolbar *ngIf="!mobile"> + + <ion-row class="ion-no-padding" nowrap> + <ion-col></ion-col> + + <!-- buttons --> + <ion-col size="auto"> + <ion-button fill="clear" color="dark" (click)="cancel()"> + <ion-label translate>COMMON.BTN_CANCEL</ion-label> + </ion-button> + + <ion-button [fill]="form.invalid ? 'clear' : 'solid'" + [disabled]="loading || form.invalid" + (click)="doSubmit()" + (keyup.enter)="doSubmit()" + color="tertiary"> + <ion-label translate>COMMON.BTN_LOGIN</ion-label> + </ion-button> + </ion-col> + </ion-row> + + + </ion-toolbar> +</ion-content> diff --git a/src/app/unlock/unlock.modal.scss b/src/app/unlock/unlock.modal.scss new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/app/unlock/unlock.modal.scss @@ -0,0 +1 @@ + diff --git a/src/app/unlock/unlock.modal.ts b/src/app/unlock/unlock.modal.ts new file mode 100644 index 0000000..b9e2350 --- /dev/null +++ b/src/app/unlock/unlock.modal.ts @@ -0,0 +1,101 @@ +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnInit, ViewChild} from '@angular/core'; +import {ModalController} from '@ionic/angular'; +import {AccountService} from '@app/wallet/account.service'; +import {firstNotNilPromise} from '@app/shared/observables'; +import {UnlockForm} from "@app/unlock/unlock.form"; + +export interface UnlockModalOptions { + title?: string; + expectedCode?: string; + minLength?: number; + maxLength?: number; +} + +@Component({ + selector: 'app-unlock-modal', + templateUrl: 'unlock.modal.html', + styleUrls: ['./unlock.modal.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class UnlockModal implements OnInit, UnlockModalOptions{ + + get loading() { + return this.form?.loading; + } + + get mobile(): boolean { + return this.form?.mobile; + } + + @Input() title = 'UNLOCK.TITLE'; + @Input() expectedCode: string = null; + @Input() minLength: number = 5; + @Input() maxLength: number = 5; + + @ViewChild('form', { static: true }) private form: UnlockForm; + + constructor(private accountService: AccountService, + private viewCtrl: ModalController, + private cd: ChangeDetectorRef + ) { + } + + ngOnInit() { + + this.form.markAsReady({emitEvent: false}); + this.form.markAsLoaded(); + this.form.enable(); + } + + cancel() { + this.viewCtrl.dismiss(); + } + + async doSubmit(data?: string): Promise<any> { + console.debug('[auth-modal] Submit...'); + if (this.form.disabled) return; + if (!this.form.valid) { + this.form.markAllAsTouched(); + return; + } + this.markAsLoading(); + + try { + data = data || this.form.value; + + // Disable the form + this.form.disable(); + + return this.viewCtrl.dismiss(data); + } + catch (err) { + this.form.error = err && err.message || err; + this.markAsLoaded(); + + // Enable the form + this.form.enable(); + + // Reset form error on next changes + firstNotNilPromise(this.form.form.valueChanges).then(() => { + this.form.error = null; + this.markForCheck(); + }); + + return; + } + } + + protected markForCheck() { + this.cd.markForCheck(); + } + + protected markAsLoading(opts?: {emitEvent?: boolean}) { + this.form.markAsLoading(opts); + this.markForCheck(); + } + + protected markAsLoaded(opts?: {emitEvent?: boolean}) { + this.form.markAsLoaded(opts); + this.markForCheck(); + } +} diff --git a/src/app/unlock/unlock.module.ts b/src/app/unlock/unlock.module.ts index b796195..82d2d7c 100644 --- a/src/app/unlock/unlock.module.ts +++ b/src/app/unlock/unlock.module.ts @@ -8,18 +8,23 @@ import {RegisterModal} from "@app/register/register.modal"; import {TranslateModule} from "@ngx-translate/core"; import {AppSharedModule} from "@app/shared/shared.module"; import {UnlockForm} from "@app/unlock/unlock.form"; +import {UnlockModal} from "@app/unlock/unlock.modal"; @NgModule({ - imports: [ - CommonModule, - FormsModule, - IonicModule, - TranslateModule, - AppSharedModule - ], - exports: [ - UnlockForm - ], - declarations: [UnlockForm] + imports: [ + CommonModule, + FormsModule, + IonicModule, + TranslateModule, + AppSharedModule + ], + declarations: [ + UnlockForm, + UnlockModal + ], + exports: [ + UnlockForm, + UnlockModal + ], }) export class AppUnlockModule {} diff --git a/src/app/wallet/account.model.ts b/src/app/wallet/account.model.ts index b2449da..cb89529 100644 --- a/src/app/wallet/account.model.ts +++ b/src/app/wallet/account.model.ts @@ -18,12 +18,16 @@ export interface AccountMeta { avatar?: string; email?: string; } +export interface Tx { +} export interface AccountData { randomId?: string; free?: number; reserved?: number; feeFrozen?: number; + + txs: Tx[]; } diff --git a/src/app/wallet/account.service.ts b/src/app/wallet/account.service.ts index d21108d..1690de1 100644 --- a/src/app/wallet/account.service.ts +++ b/src/app/wallet/account.service.ts @@ -1,4 +1,4 @@ -import {Injectable} from "@angular/core"; +import {Inject, Injectable} from "@angular/core"; import {NetworkService} from "../network/network.service"; import {ApiPromise, Keyring} from "@polkadot/api"; import {Account, AccountMeta, AccountUtils} from "./account.model"; @@ -6,34 +6,76 @@ import {StartableService} from "@app/shared/services/startable-service.class"; import {AuthData} from "@app/auth/auth.model"; import {keyring} from "@polkadot/ui-keyring"; import {environment} from "@environments/environment"; -import {StorageService} from "@app/shared/services/storage/storage.service"; -import {KeyringStorage} from "@app/shared/services/keyring-storage"; +import {KeyringStorage} from "@app/shared/services/storage/keyring-storage"; import {RegisterData} from "@app/register/register.model"; import {cryptoWaitReady, mnemonicGenerate} from '@polkadot/util-crypto'; -import {isNilOrNaN, isNotEmptyArray} from "@app/shared/functions"; -import {Inject} from "@angular/core"; -import {IStorage, APP_STORAGE} from "@app/shared/services/storage/storage.interface"; -import {BehaviorSubject, from, map, Observable, switchMap} from "rxjs"; +import { + isEmptyArray, + isNil, + isNilOrBlank, + isNilOrNaN, + isNotEmptyArray, isNotNil, + isNotNilOrBlank, + sleep +} from "@app/shared/functions"; +import {APP_STORAGE, IStorage} from "@app/shared/services/storage/storage.utils"; +import { + BehaviorSubject, + debounceTime, + firstValueFrom, + from, + lastValueFrom, + map, + Observable, + skip, Subscription, + switchMap, timer +} from "rxjs"; +import {ModalController} from "@ionic/angular"; +import {UnlockModal, UnlockModalOptions} from "@app/unlock/unlock.modal"; +import {Currency} from "@app/network/currency.model"; +import {SettingsService} from "@app/settings/settings.service"; +import {KeyringAddress} from "@polkadot/ui-keyring/types"; const scrypt = require('scrypt-async'); +export interface LoadAccountDataOptions { + reload?: boolean; + withTx?: boolean; + withBalance?: boolean; +} @Injectable({providedIn: 'root'}) export class AccountService extends StartableService { private _$accounts = new BehaviorSubject<Account[]>([]); private _store = new KeyringStorage(this.storage); + private readonly _isDevelopment: boolean; + private _password: string = null; + private _passwordTimer: Subscription; get api(): ApiPromise { return this.network.api; } + get accounts(): Account[] { + return this._$accounts.value; + } + + get isLogin(): boolean { + return this.started && isNotEmptyArray(this.accounts); + } + constructor( protected network: NetworkService, + protected settings: SettingsService, + protected modalController: ModalController, @Inject(APP_STORAGE) protected storage: IStorage ) { super(network, { - name: 'wallet-service' + name: 'account-service' }); + + // DEV mode + this._isDevelopment = !environment.production; } protected async ngOnStart(): Promise<any> { @@ -41,24 +83,52 @@ export class AccountService extends StartableService { // Wait crypto to be loaded by browser await cryptoWaitReady(); - keyring.setDevMode(!environment.production); + const currency = this.network.currency; + + // Configure keyring + keyring.setDevMode(this._isDevelopment); + keyring.setSS58Format(currency.ss58Format || 42 /* = dev format */); - // Set the default SS58 format - const ss58Format = this.network.currency?.ss58Format || 42; // 42 = dev format - keyring.setSS58Format(ss58Format); + // Restoring accounts + await this.restoreAccounts(currency); + // Add test account --- DEV only + if (this._isDevelopment) { + const auth = environment.dev?.auth; + // Set password + this._password = auth?.password || 'AAAAA'; + + // Add a V1 Dev account + if (auth?.v1) { + await this.addV1Account({...auth.v1, meta: auth.meta}); + } + } + } + + async restoreAccounts(currency?: Currency) { // load all available addresses and accounts const now = Date.now(); - console.info('Loading accounts...'); + console.info('[account-service] Loading all accounts...'); + + // Prepare an observable, to known when keyring.loadAll() will be ready + const accounts$ = keyring.accounts.subject + .pipe( + debounceTime(250), + map(_ => keyring.getAccounts()) + ) + keyring.loadAll({ store: this._store, - ss58Format, - type: 'sr25519', - isDevelopment: !environment.production + ss58Format: currency?.ss58Format, + genesisHash: currency?.genesys, + isDevelopment: this._isDevelopment }); - const keyringAddresses = keyring.getAccounts(); - if (isNotEmptyArray(keyringAddresses)) { + const keyringAddresses = await firstValueFrom(accounts$); + if (isEmptyArray(keyringAddresses)) { + console.info('[account-service] Loading all accounts [OK] No account found'); + } + else { const accounts = keyringAddresses.map(ka => { return <Account>{ address: ka.address, @@ -73,19 +143,14 @@ export class AccountService extends StartableService { // Load account's data await Promise.all(accounts.map(a => this.loadData(a))); - // Log + // DEBUG console.info(`Loading accounts [OK] ${accounts.length} accounts loaded in ${Date.now() - now}ms`); accounts.forEach(a => { - console.info(` - ${a.address} (${a.meta?.name}) - free=${a.data.free} - reserved=${a.data.reserved}`); + console.info(` - ${a.address} (${a.meta?.name}) - free=${a.data?.free} - reserved=${a.data?.reserved}`); }); this._$accounts.next(accounts); } - - // Auto login - if (!environment.production && environment.dev?.auth) { - setTimeout(() => this.login(environment.dev.auth)); - } } async login(auth: AuthData): Promise<Account> { @@ -94,14 +159,59 @@ export class AccountService extends StartableService { await this.ready(); if (auth.v1) { - return this.loginV1({...auth.v1}); + return this.addV1Account({...auth.v1, meta: auth.meta}); } // TODO //return this._accounts[0]; } - async generateNew() { + async auth(): Promise<boolean> { + if (isNotNilOrBlank(this._password)) { + console.debug(`${this._logPrefix}Already authenticated. Skip`); + return true; // ok + } + + console.debug(`${this._logPrefix}Not auth: opening unlock modal...`); + + const modal = await this.modalController.create({ + component: UnlockModal, + componentProps: <UnlockModalOptions>{ + } + }); + await modal.present(); + const {data, role} = await modal.onWillDismiss(); + + // User cancelled + if (isNilOrBlank(data)) { + console.debug(`${this._logPrefix}Not auth: cancelled`); + return false; + } + + this._password = data as string; + + // Un auth after a delay + this._passwordTimer?.unsubscribe(); + const resetDelay = Math.max(this.settings.data?.unAuthDelayMs || 0, 5000); // 5s min + this._passwordTimer = timer(resetDelay) + .subscribe(() => { + + if (isNotNil(this._password)) { + this._password = null; + + // Lock all pairs + (this._$accounts.value || []) + .map(a => keyring.getPair(a.address)) + .filter(pair => pair.isLocked) + .forEach(pair => pair.lock()); + } + this._passwordTimer?.unsubscribe(); + this._passwordTimer = null; + }); + return true; + } + + async generateMnemonic() { if (!this.started) await this.ready(); // generate a random mnemonic, 12 words in length @@ -110,6 +220,21 @@ export class AccountService extends StartableService { return mnemonic; } + async createAddress(data: RegisterData, save?: boolean): Promise<Account> { + // add the account, encrypt the stored JSON with an account-specific password + const { pair, json } = keyring.addUri(data.mnemonic, data.password, { + name: data.meta?.name || 'default', + genesisHash: this.network.currency?.genesys + }, 'sr25519'); + + return { + address: json.address, + meta: { + name: data.meta?.name + } + }; + } + async register(data: RegisterData): Promise<boolean> { // add the account, encrypt the stored JSON with an account-specific password @@ -118,8 +243,6 @@ export class AccountService extends StartableService { genesisHash: this.network.currency?.genesys }, 'sr25519'); - keyring.saveAccount(pair, data.password); - //this.debug('check pair', pair, json); await this.addAccount({ @@ -136,11 +259,11 @@ export class AccountService extends StartableService { let accounts = this._$accounts.value || []; const existingAccount = accounts.find(a => a.address === account.address); if (existingAccount) { - console.warn(`Account with address '${account.address}' already added. Skip`); + console.warn(`${this._logPrefix}Account with address '${account.address}' already added. Skip`); account = existingAccount; } else { - console.info(`Add account with address '${account.address}'`); + console.info(`${this._logPrefix}Add account with address '${account.address}'`); // Define as default if (account.default || accounts.length === 1) await this.setDefaultAccount(account, accounts); @@ -208,7 +331,7 @@ export class AccountService extends StartableService { // && keyring.isAvailable(address); } - async getDefault(opts?: { withTx?: boolean; reload?: boolean }): Promise<Account> { + async getDefault(opts?: LoadAccountDataOptions): Promise<Account> { if (!this.started) await this.ready(); const accounts = this._$accounts.value || []; @@ -227,7 +350,7 @@ export class AccountService extends StartableService { return await this.loadData(account, opts); } - async getByName(name: string, opts?: { withTx?: boolean; reload?: boolean }): Promise<Account> { + async getByName(name: string, opts?: LoadAccountDataOptions): Promise<Account> { if (!this.started) await this.ready(); const accounts = this._$accounts.value || []; @@ -238,7 +361,7 @@ export class AccountService extends StartableService { return await this.loadData(account, opts); } - async getByAddress(address: string, opts?: { withTx?: boolean; reload?: boolean }): Promise<Account> { + async getByAddress(address: string, opts?: LoadAccountDataOptions): Promise<Account> { if (!this.started) await this.ready(); const accounts = this._$accounts.value || []; @@ -264,31 +387,51 @@ export class AccountService extends StartableService { // the address we use to use for signing, as injected //const issuer = from.address ? await this.getByAddress(from.address) : await this.getByAddress(from.meta?.name); - console.info(`[account-service] Sending ${amount} :\nfrom: ${from.address}\nto ${to.address}`) const issuerAccount = await this.getByAddress(from.address); - const issuerPair = keyring.getPair(issuerAccount.address); - const toOwner = await this.isAvailable(to.address); + // Not enough credit + if (amount > issuerAccount.data.free) { + throw new Error('ERROR.NOT_ENOUGH_CREDIT'); + } + + console.info(`[account-service] Sending ${amount} :\nfrom: ${from.address}\nto ${to.address}`) + const issuerPair = keyring.getPair(issuerAccount.address); const convertedAmount = Math.floor(amount * 100); - // TODO display unlock modal if need - issuerPair.unlock('test'); + // Unlock + if (issuerPair.isLocked) { + console.debug(`[account-service] Unlocking address ${from.address} ...`); + const isAuth = await this.auth(); + if (!isAuth) throw new Error('ERROR.AUTH_REQUIRED'); + issuerPair.unlock(this._password); + } try { // Sign and send a transfer from Alice to Bob const txHash = await this.api.tx.balances .transfer(to.address, convertedAmount) - .signAndSend(issuerPair, async ({status}) => { + .signAndSend(issuerPair, async ({status, events, findRecord}) => { if (status.isInBlock) { - console.info('Completed at block hash #' + status.hash.toHuman()); + console.info(`${this._logPrefix}Completed at block hash #${status.hash.toHuman()}`); + + if (this._debug) console.debug(`${this._logPrefix}Block events:`, JSON.stringify(events)); - if (toOwner) { + await sleep(200); + // Update issuer account + //issuerAccount.data.free -= amount; + + // Update receiver account + if (await this.isAvailable(to.address)) { const toAccount = await this.getByAddress(to.address); - await this.loadData(toAccount); + //toAccount.data.free += amount; + + await this.loadData(toAccount, {reload: true}); } - await this.loadData(issuerAccount); + await this.loadData(issuerAccount, {reload: true}); + + // Notify account changes this.notifyChanged(); } else { @@ -308,26 +451,41 @@ export class AccountService extends StartableService { } } - private async loadData(account: Account, opts?: {reload?: boolean}): Promise<Account> { - if (!!account.data && opts?.reload !== true) return account; // Already loaded: skip - - const {data} = await this.api.query.system.account(account.address); + private async loadData(account: Account, opts?: LoadAccountDataOptions): Promise<Account> { + opts = { + reload: false, + withBalance: true, + withTx: false, // disable by default + ...opts + }; + + // Load balance (free + reserved) + if (opts.withBalance === true && (isNil(account.data?.free) || opts.reload === true)) { + const {data} = await this.api.query.system.account(account.address); + account.data = { + ...account.data, + ...JSON.parse(data.toString()) + }; + } - account.data = JSON.parse(data.toString()); - this.debug(`Loaded ${account.address} data:`, account.data); + // Load TX + if (opts.withTx === true && (isNil(account.data?.txs) || opts.reload === true)) { + console.warn('[account-service] TODO - Implement load Tx'); + } + console.debug(`${this._logPrefix} Loaded ${account.address} data:`, account.data); return account; } private notifyChanged() { - this._$accounts.next(this._$accounts.value); + this._$accounts.next(this._$accounts.value.slice() /*create a copy*/); } - async loginV1(data: {salt: string, password: string; meta?: AccountMeta}): Promise<Account> { + async addV1Account(data: {salt: string, password: string; meta?: AccountMeta}): Promise<Account> { if (!data?.salt || !data?.password) return; - this.log('Authenticating using salt+pwd...'); + console.info(this._logPrefix + ' Authenticating using salt+pwd...'); const rawSeedString = await new Promise((resolve) => { scrypt(data.password, data.salt, { @@ -339,19 +497,17 @@ export class AccountService extends StartableService { }, (result) => resolve(result)); }); - console.info(rawSeedString); + //console.debug('Computed seed (hex) from salt+pwd:', rawSeedString); const meta = { - name: data.meta?.name, + name: data.meta?.name || 'V1', genesisHash: this.network.currency?.genesys } - const {pair, json} = await keyring.addUri(`0x${rawSeedString}`, data.password, meta, 'ed25519'); + const isAuth = await this.auth(); + if (!isAuth) return; // Skip - pair.unlock(data.password); - - keyring.saveAccount(pair, data.password); - keyring.addPair(pair, data.password); + const {pair, json} = await keyring.addUri(`0x${rawSeedString}`, this._password, meta, 'ed25519'); const account = this.addAccount({ address: json.address, @@ -363,4 +519,12 @@ export class AccountService extends StartableService { return account; } + forgetAll() { + if (!this._isDevelopment) { + (this._$accounts.value || []).forEach(account => { + keyring.forgetAccount(account.address); + }); + } + this._$accounts.next([]); + } } diff --git a/src/app/wallet/wallet.module.ts b/src/app/wallet/wallet.module.ts index 1c174bd..da74b29 100644 --- a/src/app/wallet/wallet.module.ts +++ b/src/app/wallet/wallet.module.ts @@ -5,13 +5,15 @@ import {WalletPageRoutingModule} from "./wallet-routing.module"; import {AppSharedModule} from "@app/shared/shared.module"; import {TranslateModule} from "@ngx-translate/core"; import {AppAuthModule} from "@app/auth/auth.module"; +import {AppUnlockModule} from "@app/unlock/unlock.module"; @NgModule({ imports: [ AppSharedModule, TranslateModule.forChild(), WalletPageRoutingModule, - AppAuthModule + AppAuthModule, + AppUnlockModule ], declarations: [WalletPage] }) diff --git a/src/app/wallet/wallet.page.html b/src/app/wallet/wallet.page.html index 3b62d16..dfd6313 100644 --- a/src/app/wallet/wallet.page.html +++ b/src/app/wallet/wallet.page.html @@ -19,12 +19,18 @@ </ion-select> </ng-container> </ion-toolbar> + <ion-progress-bar type="indeterminate" *ngIf="loading"></ion-progress-bar> </ion-header> <ion-content [fullscreen]="true"> <ion-header> <ion-toolbar class="ion-text-end" color="secondary"> - <ion-title size="large">{{ balance | amountFormat }}</ion-title> + <ion-title size="large" *ngIf="loaded; else loadingText"> + {{ balance | amountFormat }} + </ion-title> + <ng-template #loadingText> + <ion-title translate>COMMON.LOADING</ion-title> + </ng-template> </ion-toolbar> </ion-header> @@ -39,12 +45,14 @@ <ion-item> <ion-icon slot="start" name="key"></ion-icon> <ion-label color="medium" translate>COMMON.PUBKEY</ion-label> - <ion-input *ngIf="loaded; else inputSkeleton" + <ion-input *ngIf="loaded; else skeleton60" class="ion-text-end" [value]="data?.address" readonly> </ion-input> - <ion-button slot="end" (click)="copyAddress()" fill="clear" [title]="'COMMON.COPY'|translate"> + <ion-button slot="end" (click)="copyAddress()" + [disabled]="loading" + fill="clear" [title]="'COMMON.COPY'|translate"> <ion-icon slot="icon-only" name="copy"></ion-icon> </ion-button> </ion-item> @@ -72,7 +80,7 @@ </div> </ion-content> -<ng-template #inputSkeleton> +<ng-template #skeleton60> <ion-skeleton-text [animated]="true" style="width: 60%"></ion-skeleton-text> </ng-template> diff --git a/src/app/wallet/wallet.page.ts b/src/app/wallet/wallet.page.ts index 97a1e74..28a0746 100644 --- a/src/app/wallet/wallet.page.ts +++ b/src/app/wallet/wallet.page.ts @@ -45,12 +45,16 @@ export class WalletPage extends BasePage<Account> implements OnInit, AfterViewCh protected networkService: NetworkService, protected accountService: AccountService ) { - super(injector, {name: 'wallet-page'}) + super(injector, { + name: 'wallet-page', + loadDueTime: accountService.started ? 0 : 250 + }) this.address = this.activatedRoute.snapshot.paramMap.get('address'); } - ngOnInit() { + async ngOnInit() { + console.info(this._logPrefix + 'Initializing...'); super.ngOnInit(); } @@ -58,12 +62,14 @@ export class WalletPage extends BasePage<Account> implements OnInit, AfterViewCh // force page reload, when auth was previously cancelled if (!this.loading && !this.data) { + this.info('Reloading page...'); setTimeout(() => this.load()); } } protected async ngOnLoad(): Promise<Account> { + this.info('Loading page...'); this.currency = this.networkService.currencySign; const accounts = await this.accountService.getAll(); @@ -108,7 +114,7 @@ export class WalletPage extends BasePage<Account> implements OnInit, AfterViewCh if (!this.authModal.isOpen) { await this.authModal.present(); } - const {data, role} = await this.authModal.onDidDismiss(); + const {data, role} = await this.authModal.onWillDismiss(); if (!data?.address) return null; return data; } diff --git a/src/assets/i18n/fr.json b/src/assets/i18n/fr.json index 5696f0f..a0429a1 100644 --- a/src/assets/i18n/fr.json +++ b/src/assets/i18n/fr.json @@ -129,7 +129,7 @@ "FORK_ME": "Clonez-moi !", "SHOW_LICENSE": "Voir la license de l'application", "REPORT_ISSUE": "anomalie", - "NOT_YOUR_ACCOUNT_QUESTION" : "Vous n'êtes pas propriétaire du compte <b class=\"ion-key\"> {{pubkey|formatPubkey}}</b> ?", + "NOT_YOUR_ACCOUNT_QUESTION" : "Vous n'êtes pas propriétaire du compte <b class=\"ion-key\"> {{pubkey}}</b> ?", "BTN_CHANGE_ACCOUNT": "Déconnecter ce compte", "CONNECTION_ERROR": "Nœud <b>{{server}}</b> injoignable ou adresse invalide.<br/><br/>Vérifiez votre connexion Internet, ou changer de nœud <a class=\"positive\" ng-click=\"doQuickFix('settings')\">dans les paramètres</a>.", "SHOW_ALL_FEED": "Voir tout", @@ -473,6 +473,7 @@ "TITLE": "<i class=\"icon ion-locked\"></i> Authentification", "BTN_AUTH": "S'authentifier", "PASSPHRASE": "Code secret", + "PASSPHRASE_HELP": "Veuillez vous authentifier, en tapant votre code secret :", "GENERAL_HELP": "Veuillez vous authentifier :", "EXPECTED_UID_HELP": "Veuillez vous authentifier sur le compte <i class=\"ion-person\"></i> {{uid}} :", "EXPECTED_PUBKEY_HELP": "Veuillez vous authentifier sur le portefeuille <br class=\"visible-xs\"/><i class=\"ion-key\"></i> {{pubkey|formatPubkey}} :", @@ -521,22 +522,22 @@ }, "NEW": { "TITLE": "Création de compte", - "INTRO_WARNING_TIME": "La création d'un compte sur {{name}} est très simple. Veuillez néanmoins prendre suffisament de temps pour faire correctement cette formalité (pour ne pas oublier les identifiants, mots de passe, etc.).", + "INTRO_WARNING_TIME": "La création d'un compte sur {{name}} est très simple. Veuillez néanmoins prendre suffisamment de temps pour faire correctement cette formalité.", "INTRO_WARNING_SECURITY": "Vérifiez que le matériel que vous utilisez actuellement (ordinateur, tablette, téléphone) <b>est sécurisé et digne de confiance</b>.", "INTRO_WARNING_SECURITY_HELP": "Anti-virus à jour, pare-feu activé, session protégée par mot de passe ou code pin, etc.", "INTRO_HELP": "Cliquez sur <b>Commencer</b> pour débuter la création de compte. Vous serez guidé étape par étape.", - "REGISTRATION_NODE": "Votre inscription sera enregistrée via le noeud Duniter <b>{{server}}</b>, qui le diffusera ensuite au reste du réseau de la monnaie.", - "REGISTRATION_NODE_HELP": "Si vous ne faites pas confiance en ce noeud, veuillez en changer <a ng-click=\"doQuickFix('settings')\">dans les paramètres</a> de Cesium.", - "SELECT_ACCOUNT_TYPE": "Choisissez le type de compte à créer :", - "MEMBER_ACCOUNT": "Compte membre", - "MEMBER_ACCOUNT_TITLE": "Création d'un compte membre", - "MEMBER_ACCOUNT_HELP": "Vous connaissez suffisamment la monnaie libre et vous voulez participer à sa création ?<br/>En tant qu'individu, vous pouvez créer votre compte membre (un seul par individu). Celui-ci fonctionne comme un compte simple portefeuille, mais permet en plus de co-produire la monnaie, en <b>recevant chaque {{parameters.dt|formatPeriod}} un dividende universel</b> : à vous ensuite d'en faire bon usage !", - "WALLET_ACCOUNT": "Compte simple portefeuille", - "WALLET_ACCOUNT_TITLE": "Création d'un portefeuille", - "WALLET_ACCOUNT_HELP": "Vous <b>découvrez la monnaie libre</b> ? Vous avez besoin d'un compte supplémentaire ?<br/>Ce type de compte vous conviendra. Bien qu'il ne co-produise la monnaie (contrairement à un compte membre - voir ci-dessous), vous pourrez y recevoir et envoyer des paiements, dès la fin de l'inscription.<br/>Si besoin, vous pourrez le transformer en compte membre ultérieurement.", - "SALT_WARNING": "Choisissez votre identifiant secret.<br/>Il vous sera demandé à chaque connexion sur ce compte.<br/><br/><b>Retenez le bien</b> : en cas de perte, plus personne ne pourra accéder à votre compte !", - "PASSWORD_WARNING": "Choisissez un mot de passe.<br/>Il vous sera demandé à chaque connexion sur ce compte.<br/><br/><b>Retenez bien ce mot de passe</b : en cas de perte, plus personne ne pourra accéder à votre compte !", - "PSEUDO_WARNING": "Choisissez un pseudonyme.<br/>Il sert aux autres membres, pour vous identifier plus facilement.<div class='hidden-xs'><br/>Il <b>ne pourra pas être modifié</b>, sans refaire un compte.</div><br/><br/>Il ne doit contenir <b>ni espace, ni de caractère accentué</b>.<div class='hidden-xs'><br/>Exemple : <span class='gray'>SophieDupond, MarcelChemin, etc.</span>", + "STEP_1_HELP": "Cesium va générer <b>phrase de restauration</b>. Elle est un peu comme un plan, qui permet de fabriquer les clefs d'accès à votre compte.", + "STEP_2_HELP": "Conservez cette phrase précieusement, car sans elle Cesium ne pourra pas reconstruire les clefs d'accès à votre compte. <br/>Vous en aurez besoin dès que vous devez installer Cesium sur un nouveau téléphone ou ordinateur.", + "STEP_3_HELP": "Dans une blockchain, pas de procédure de récupération par mail ou SMS. Seule votre phrase de restauration peut vous permettre de récupérer vos {{currency}} à tout moment.", + "STEP_4_HELP": "Il est temps de vous munir d'<b>un papier et d'un crayon</b> afin de pouvoir noter votre phrase de restauration.", + "STEP_MNEMONIC_HELP": "Voici votre phrase de restauration !<br/>Tâcher de la garder bien secrète, car quiconque la trouve pourra vider votre compte.", + "STEP_CHECK_WORD_HELP": "Avez-vous bien noté votre phrase de restauration ?<br/>Pour en être sûr, veuillez taper dans le champs ci-dessous le mot n°{{number}} de la phrase :", + "STEP_CODE_1_HELP": "<b>Un code secret</b> va maintenant être généré.<br/>Il est nécessaire pour accéder à votre compte sur cet appareil, sans avoir à utiliser la phrase de restauration.", + "STEP_CODE_2_HELP": "Ce code secret a aussi pour but de <b>protèger aussi votre compte</b>. C'est pourquoi <b>vous seul devez le posséder</b>.", + "STEP_CODE_3_HELP": "Voici votre code secret !<br/>Mémorisez-le parfaitement, ou notez-le, car il vous sera demandé <b>à chaque fois</b> que voudrez effectuer un paiement sur cet appareil. Un peu comme le code pin pour un paiement par carte bancaire.", + "STEP_CHECK_CODE_HELP": "Pour vérifier que vous avez bien mémorisé ou noté votre code secret, veuillez le taper ci-dessous :", + "STEP_CONGRATULATION_1_HELP": "Bravo ! Tout est prêt pour créer votre compte.<br/><br/>Celui-ci aura le <b>numéro de compte</b> suivant :", + "STEP_CONGRATULATION_2_HELP": "Cesium pourra aussi afficher ce numéro de compte <b>sous une forme plus compacte</b> :", "PSEUDO": "Pseudonyme", "PSEUDO_HELP": "Pseudonyme", "SALT_CONFIRM": "Confirmation", diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index cfebbcf..3c8d152 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -1,4 +1,5 @@ import {Environment} from "./environment.class"; +import {StorageDrivers} from "@app/shared/services/storage/storage.utils"; export const environment = <Environment>{ production: true, @@ -8,7 +9,7 @@ export const environment = <Environment>{ // Storage storage: { - driverOrder: ['sqlite', 'indexeddb', 'websql', 'localstorage'] + driverOrder: [StorageDrivers.IndexedDB, StorageDrivers.WebSQL, StorageDrivers.LocalStorage] }, defaultPeers: [ diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 9b1ef98..847c805 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -5,9 +5,9 @@ import {Environment} from "./environment.class"; import {AuthData} from "@app/auth/auth.model"; import {Drivers} from "@ionic/storage"; +import {StorageDrivers} from "@app/shared/services/storage/storage.utils"; export const environment = <Environment>{ - //production: true, production: false, name: 'Cesium', @@ -17,7 +17,7 @@ export const environment = <Environment>{ // Storage storage: { name: 'cesium', - driverOrder: ['localForage-cordovaSQLiteDriver', Drivers.IndexedDB, Drivers.LocalStorage] + driverOrder: [StorageDrivers.IndexedDB, StorageDrivers.WebSQL, StorageDrivers.LocalStorage] }, keyring: { diff --git a/src/global.scss b/src/global.scss index d854de8..8c42b86 100644 --- a/src/global.scss +++ b/src/global.scss @@ -1,26 +1,21 @@ -/* - * App Global CSS - * ---------------------------------------------------------------------------- - * Put style rules here that you want to apply globally. These styles are for - * the entire app and not just one component. Additionally, this file can be - * used as an entry point to import other CSS/Sass files to be included in the - * output CSS. - * For more information on global stylesheets, visit the documentation: - * https://ionicframework.com/docs/layout/global-stylesheets - */ +@import "theme"; -/* Core CSS required for Ionic components to work properly */ -@import "~@ionic/angular/css/core.css"; +* { + @include font-roboto(); +} -/* Basic CSS for apps built with Ionic */ -@import "~@ionic/angular/css/normalize.css"; -@import "~@ionic/angular/css/structure.css"; -@import "~@ionic/angular/css/typography.css"; -@import '~@ionic/angular/css/display.css'; +// -------------------------------------------------- +// Inject theme colors (as CSS variables) +// -------------------------------------------------- +:root { + @include css-variables-to-root(); -/* Optional CSS utils that can be commented out */ -@import "~@ionic/angular/css/padding.css"; -@import "~@ionic/angular/css/float-elements.css"; -@import "~@ionic/angular/css/text-alignment.css"; -@import "~@ionic/angular/css/text-transformation.css"; -@import "~@ionic/angular/css/flex-utils.css"; + // Extra small device + @media screen and (max-width: #{$screen-xs-max}) { + --mat-toolbar-height: mat.$toolbar-height-mobile; + } + + ion-icon { + pointer-events: none !important; + } +} diff --git a/src/theme/_cesium.globals.scss b/src/theme/_cesium.globals.scss index 9cfe7ec..ddeb4ca 100644 --- a/src/theme/_cesium.globals.scss +++ b/src/theme/_cesium.globals.scss @@ -1,5 +1,6 @@ $app-menu-width: 255px !default; $app-toolbar-height: 60px !default; // = $mat-toolbar-height-mobile + 4 (progress bar); - $app-footer-height: 56px !default; + +$app-popover-width: 320px !default;; diff --git a/src/theme/_cesium.scss b/src/theme/_cesium.scss new file mode 100644 index 0000000..247c9b1 --- /dev/null +++ b/src/theme/_cesium.scss @@ -0,0 +1,9 @@ + +.select-popover-large .popover-content{ + width: #{$app-popover-width} !important; +} + +.cdk-visually-hidden { + display: none; + visibility: hidden; +} diff --git a/src/theme/_functions.scss b/src/theme/_functions.scss new file mode 100644 index 0000000..73564c8 --- /dev/null +++ b/src/theme/_functions.scss @@ -0,0 +1,7 @@ +//@use './ionic/ionic.functions.string'; +//@use './ionic/ionic.functions.color'; + +@function calculateRem($size) { + $remSize: $size / 16px; + @return $remSize * 1rem; +} diff --git a/src/theme/_globals.scss b/src/theme/_globals.scss new file mode 100644 index 0000000..5b12ff8 --- /dev/null +++ b/src/theme/_globals.scss @@ -0,0 +1,4 @@ +@import "functions"; +@import "mixins"; +@import "ionic.globals"; +@import "cesium.globals"; diff --git a/src/theme/_ionic.globals.scss b/src/theme/_ionic.globals.scss index 103001a..103d682 100644 --- a/src/theme/_ionic.globals.scss +++ b/src/theme/_ionic.globals.scss @@ -75,6 +75,12 @@ $ion-grid-padding: 16px !default; $ion-padding: 16px !default; $ion-margin: 16px !default; +// Popover +// -------------------------------------------------- +$popover-md-width: 320px; +$popover-ios-width: 320px; +$popover-wp-width: 320px; + // -------------------------------------------------- // Default General Colors // -------------------------------------------------- diff --git a/src/theme/_ionic.scss b/src/theme/_ionic.scss new file mode 100644 index 0000000..e389be3 --- /dev/null +++ b/src/theme/_ionic.scss @@ -0,0 +1,25 @@ +/* + * App Global CSS + * ---------------------------------------------------------------------------- + * Put style rules here that you want to apply globally. These styles are for + * the entire app and not just one component. Additionally, this file can be + * used as an entry point to import other CSS/Sass files to be included in the + * output CSS. + * For more information on global stylesheets, visit the documentation: + * https://ionicframework.com/docs/layout/global-stylesheets + */ +/* Core CSS required for Ionic components to work properly */ +@import "~@ionic/angular/css/core.css"; + +/* Basic CSS for apps built with Ionic */ +@import "~@ionic/angular/css/normalize.css"; +@import "~@ionic/angular/css/structure.css"; +@import "~@ionic/angular/css/typography.css"; +@import '~@ionic/angular/css/display.css'; + +/* Optional CSS utils that can be commented out */ +@import "~@ionic/angular/css/padding.css"; +@import "~@ionic/angular/css/float-elements.css"; +@import "~@ionic/angular/css/text-alignment.css"; +@import "~@ionic/angular/css/text-transformation.css"; +@import "~@ionic/angular/css/flex-utils.css"; diff --git a/src/theme/_mixins.scss b/src/theme/_mixins.scss new file mode 100644 index 0000000..08c9722 --- /dev/null +++ b/src/theme/_mixins.scss @@ -0,0 +1,105 @@ + +@mixin font-size($size) { + font-size: calculateRem($size); +} + +@mixin font-size-important($size) { + font-size: calculateRem($size) !important; +} + + +@mixin font-roboto($size: false, $colour: false, $weight: false, $lh: false) { + font-family: 'Roboto', Helvetica, Arial, sans-serif; + + @if $size { + font-size: $size; + } + + @if $colour { + color: $colour; + } + + @if $weight { + font-weight: $weight; + } + + @if $lh { + line-height: $lh; + } +} + +@mixin color($color-name) { + $value: map-get($colors, $color-name); + $base: map-get($value, base); + + color: var(--ion-color-#{""+$color-name}, $base); +} + +/** +* Define CSS theme colors +*/ +@mixin generate-color($color-name) { + $value: map-get($colors, $color-name); + $base: map-get($value, base); + $contrast: map-get($value, contrast); + $shade: map-get($value, shade); + $tint: map-get($value, tint); + --ion-color-base: var(--ion-color-#{""+$color-name}, + #{$base}) !important; + --ion-color-base-rgb: var(--ion-color-#{""+$color-name}-rgb, + #{color-to-rgb-list($base)}) !important; + --ion-color-contrast: var(--ion-color-#{""+$color-name}-contrast, + #{$contrast}) !important; + --ion-color-contrast-rgb: var(--ion-color-#{""+$color-name}-contrast-rgb, + #{color-to-rgb-list($contrast)}) !important; + --ion-color-shade: var(--ion-color-#{""+$color-name}-shade, + #{$shade}) !important; + --ion-color-tint: var(--ion-color-#{""+$color-name}-tint, + #{$tint}) !important; +} + +@mixin generate-color-list($color-name) { + $value: map-get($colors, $color-name); + $base: map-get($value, base); + $contrast: map-get($value, contrast); + $shade: map-get($value, shade); + $tint: map-get($value, tint); + --ion-color-#{""+$color-name} : #{$base}; + --ion-color-#{""+$color-name}-rgb: #{color-to-rgb-list($base)}; + --ion-color-#{""+$color-name}-contrast: #{$contrast}; + --ion-color-#{""+$color-name}-contrast-rgb: #{color-to-rgb-list($contrast)}; + --ion-color-#{""+$color-name}-shade: #{$shade}; + --ion-color-#{""+$color-name}-tint: #{$tint}; +} + +@mixin set-color-each-ion-colors($element-name) { + + @each $color-name, $value in $colors { + #{$element-name}.ion-color-#{""+$color-name} { + color: var(--ion-color-base); + } + } +} + +@mixin css-variables-to-root() { + + @each $color-name, + $value in $colors { + .ion-color-#{""+$color-name} { + @include generate-color($color-name); + } + } + + @each $color-name, + $value in $colors { + @include generate-color-list($color-name); + } + + --ion-grid-column-padding: #{$ion-grid-column-padding}; + --ion-padding: #{$ion-padding}; + --ion-margin: #{$ion-margin}; + + --ion-toolbar-height: #{56px}; + +} + diff --git a/src/theme/_theme.scss b/src/theme/_theme.scss new file mode 100644 index 0000000..4666bc7 --- /dev/null +++ b/src/theme/_theme.scss @@ -0,0 +1,12 @@ +// -------------------------------------------------- +// Import globals (variables, functions and mixins) +// -------------------------------------------------- +@import "globals"; +// -------------------------------------------------- +// Import Ionic style +// -------------------------------------------------- +@import "ionic"; +// -------------------------------------------------- +// Import SUMARiS style +// -------------------------------------------------- +@import "cesium"; -- GitLab