Skip to content

Commit

Permalink
moved add account modal logic to its component
Browse files Browse the repository at this point in the history
  • Loading branch information
jlcvp committed Oct 22, 2024
1 parent 1c6390b commit eb2593a
Show file tree
Hide file tree
Showing 10 changed files with 369 additions and 339 deletions.
89 changes: 86 additions & 3 deletions src/app/home/components/account-modal/account-modal.component.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,86 @@
<p>
account-modal works!
</p>
<ion-header>
<ion-toolbar>
<ion-title>{{ "ADD_ACCOUNT_MODAL.TITLE" | translate }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="dismiss()"><ion-icon slot="icon-only" name="close-outline"></ion-icon></ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ng-container *ngIf="isScanActive;else manualForm">
<ngx-scanner-qrcode #qrscanner="scanner" (event)="onQRCodeScanned($event, qrscanner)"
[config]="qrScannerOpts"></ngx-scanner-qrcode>
<ion-grid>
<ion-row>
<ion-col size="auto">
</ion-col>
<ion-col></ion-col>
<ion-col size="auto">
<ion-button size="large" (click)="cycleCamera()">
<ion-icon slot="icon-only" name="sync-outline"></ion-icon>
</ion-button>
</ion-col>
</ion-row>
<ion-row>
<ion-col size="12">
<ion-button size="large" expand="block" (click)="manualInputAction()">
<ion-icon slot="start" name="create-outline"></ion-icon>
{{ "ADD_ACCOUNT_MODAL.MANUAL_INPUT" | translate }}
</ion-button>
</ion-col>
</ion-row>
</ion-grid>
</ng-container>
<ng-template #manualForm>
<ion-button size="large" expand="block" (click)="scanCode()">
<ion-icon slot="start" name="qr-code-outline"></ion-icon>
{{ "ADD_ACCOUNT_MODAL.SCAN_QR_CODE" | translate }}
</ion-button>
<form [formGroup]="validations_form" (ngSubmit)="createAccount(validations_form.value)">
<ion-item>
<ion-input label="{{ 'ADD_ACCOUNT_MODAL.LABEL' | translate }}" labelPlacement="stacked" formControlName="label"
placeholder="{{ 'ADD_ACCOUNT_MODAL.EXAMPLI_GRATIA_SHORT' | translate }} Google:teste@gmail.com"></ion-input>
</ion-item>
<ion-item>
<ion-input label="{{ 'ADD_ACCOUNT_MODAL.SECRET_KEY' | translate }}" labelPlacement="stacked"
formControlName="secret"
placeholder="{{ 'ADD_ACCOUNT_MODAL.EXAMPLI_GRATIA_SHORT' | translate }} QAPERTPEO123"></ion-input>
</ion-item>
<ion-item>
<ion-input label="{{ 'ADD_ACCOUNT_MODAL.TOKEN_SIZE' | translate }}" labelPlacement="stacked"
formControlName="tokenLength"
placeholder="{{ 'ADD_ACCOUNT_MODAL.EXAMPLI_GRATIA_SHORT' | translate }} 6"></ion-input>
</ion-item>
<ion-item>
<ion-input label="{{ 'ADD_ACCOUNT_MODAL.TOKEN_INTERVAL' | translate }}" labelPlacement="stacked"
formControlName="interval"
placeholder="{{ 'ADD_ACCOUNT_MODAL.EXAMPLI_GRATIA_SHORT' | translate }} 30"></ion-input>
</ion-item>
<ion-item>
<ion-label position="stacked">{{ "ADD_ACCOUNT_MODAL.LOGO" | translate }}</ion-label>
<ion-searchbar placeholder="{{ 'SEARCH' | translate }}" class="ion-padding-top" [debounce]="250"
[value]="draftLogoSearchTxt" (ionInput)="handleSearchLogo($event)"></ion-searchbar>
<ion-grid class="full-row">
<ion-row class="full-row">
<ion-col size-xs="6" size-sm="4" size="4">
<ion-card class="logo-card" (click)="selectLogo('')" [ngClass]="{ 'selected-logo': !draftLogoURL }">
<ion-img class="ion-padding logo-img" [src]="'assets/icon/favicon.png'"></ion-img>
<p>{{ "ADD_ACCOUNT_MODAL.NO_LOGO" | translate }}</p>
</ion-card>
</ion-col>
<ion-col size-xs="6" size-sm="4" *ngFor="let logoResult of searchLogoResults">
<ion-card class="logo-card" (click)="selectLogo(logoResult)"
[ngClass]="{ 'selected-logo': logoResult === draftLogoURL }">
<ion-img class="ion-padding ion-margin logo-img" [src]="logoResult"></ion-img>
</ion-card>
</ion-col>
</ion-row>
</ion-grid>
<p *ngIf="searchLogoResults.length > 0">Logos provided by <a href="https://brandfetch.com">Brandfetch</a>
</p>
</ion-item>
<ion-button expand="block" [disabled]="!validations_form.valid" type="submit">{{ "ADD_ACCOUNT_MODAL.SAVE" |
translate }}</ion-button>
</form>
</ng-template>
</ion-content>
20 changes: 20 additions & 0 deletions src/app/home/components/account-modal/account-modal.component.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
.full-row {
width: 100%;
}

.selected-logo {
border: 3px solid var(--ion-color-danger);
}

.logo-img {
background-color: whitesmoke;
box-shadow: 0 0 3px 0 rgba(0, 0, 0, 0.2);
}

.logo-card {
aspect-ratio: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}

This file was deleted.

244 changes: 240 additions & 4 deletions src/app/home/components/account-modal/account-modal.component.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,250 @@
import { Component, OnInit } from '@angular/core';
import { Component, OnInit, ViewChild } from '@angular/core';
import { FormGroup, FormControl, Validators, FormBuilder } from '@angular/forms';
import { AlertController, LoadingController, ModalController, SearchbarCustomEvent, ToastController } from '@ionic/angular';
import { TranslateService } from '@ngx-translate/core';
import { NgxScannerQrcodeComponent, ScannerQRCodeConfig, ScannerQRCodeResult } from 'ngx-scanner-qrcode';
import { firstValueFrom } from 'rxjs';
import { Account2FA } from 'src/app/models/account2FA.model';
import { LogoService } from 'src/app/services/logo.service';

@Component({
selector: 'app-account-modal',
templateUrl: './account-modal.component.html',
styleUrls: ['./account-modal.component.scss'],
})
export class AccountModalComponent implements OnInit {
export class AccountModalComponent implements OnInit {
@ViewChild('qrscanner') qrscanner!: NgxScannerQrcodeComponent;

constructor() { }
isScanActive = false;
draftLogoURL = '';
draftLogoSearchTxt = '';
searchLogoResults: string[] = [];

ngOnInit() {}
qrScannerOpts: ScannerQRCodeConfig = {
isBeep: false,
vibrate: 100,
constraints: {
video: {
facingMode: 'environment'
}
}
}
validations_form: FormGroup;

private loading: HTMLIonLoadingElement | undefined = undefined

constructor(
formBuilder: FormBuilder,
private translateService: TranslateService,
private logoService: LogoService,
private toastController: ToastController,
private loadingController: LoadingController,
private alertController: AlertController,
private modalController: ModalController
) {
this.validations_form = formBuilder.group({
label: new FormControl('', Validators.compose([
Validators.required,
])),
secret: new FormControl('', Validators.compose([
Validators.required,
Validators.minLength(8),
Validators.pattern('^[A-Z2-7]+=*$')
])),
tokenLength: new FormControl(6, Validators.compose([
Validators.required,
Validators.pattern('^[1-9]+[0-9]*$')
])),
interval: new FormControl(30, Validators.compose([
Validators.required,
Validators.pattern('^[1-9]+[0-9]*$')
])),
});
}

ngOnInit(): void {
this.scanCode()
}

async onWillDismiss() {
if (this.qrscanner) {
console.log("STOP QR")
await this.qrscanner.stop()
}
}

async dismiss(data: Account2FA | null = null) {
// Dismiss the modal
const role = data ? 'added' : 'cancel'
await this.onWillDismiss()
await this.modalController.dismiss(data, role)
}

async onQRCodeScanned(evt: ScannerQRCodeResult[], qrscanner: NgxScannerQrcodeComponent) {
try {
await qrscanner.stop()
await this.qrscanner.stop()
} catch (error) {
console.error("Error stopping scanner", error)
}
this.processQRCode(evt && evt[0]?.value || '')
// give time for the scanner to stop
setTimeout(() => {
this.isScanActive = false
}, 200);
}

async cycleCamera() {
const current = this.qrscanner.deviceIndexActive
const devices = await firstValueFrom(this.qrscanner.devices)
console.log({ })
const next = (current + 1) % devices.length
const nextDevice = devices[next]
console.log(`cycle device [${current}] -> [${next}]`, { playDevice: nextDevice, devices })
await this.qrscanner.playDevice(nextDevice.deviceId)
}

manualInputAction() {
if (this.qrscanner) {
console.log("STOP QR Reading")
this.qrscanner.stop()
}
this.isScanActive = false;
}

async scanCode() {
// <ngx-scanner-qrcode #action="scanner" (event)="onEvent($event, action)"></ngx-scanner-qrcode>
this.isScanActive = true
const message = await firstValueFrom(this.translateService.get('ADD_ACCOUNT_MODAL.LOADING_CAMERA'))
await this.presentLoading(message)
try {
await firstValueFrom(this.qrscanner.start())
} catch (error) {
// camera permission denial
const header = await firstValueFrom(this.translateService.get('ADD_ACCOUNT_MODAL.ERROR_MSGS.ERROR_CAMERA_HEADER'))
const message = await firstValueFrom(this.translateService.get('ADD_ACCOUNT_MODAL.ERROR_MSGS.ERROR_CAMERA_MESSAGE'))
await this.dismissLoading()
await this.showAlert(message, header)
this.isScanActive = false
return
}

const devices = (await firstValueFrom(this.qrscanner.devices)).filter(device => device.kind === 'videoinput')
console.log({ devices })
// find back camera
let backCamera = devices.find(device => device.label.toLowerCase().includes('back camera'))
if (!backCamera) {
backCamera = devices.find(device => device.label.toLowerCase().match(/.*back.*camera.*/))
}

if (backCamera && backCamera.deviceId) {
console.log("using device", { backCamera })
await this.qrscanner.playDevice(backCamera.deviceId)
}
await this.dismissLoading()
}

async handleSearchLogo(evt: SearchbarCustomEvent) {
const searchTerm = evt?.detail?.value
console.log({ evt, searchTerm })
if (!searchTerm) {
this.draftLogoURL = ''
this.searchLogoResults = []
return
}
const brandInfo = await this.logoService.searchServiceInfo(searchTerm)
if (brandInfo && brandInfo.length > 0) {
this.draftLogoURL = brandInfo[0].logo
this.searchLogoResults = brandInfo.map(brand => brand.logo)
}
console.log({ brandInfo, draftLogoURL: this.draftLogoURL, searchTerm: this.draftLogoSearchTxt, results: this.searchLogoResults })
}

selectLogo(logoURL: string) {
this.draftLogoURL = logoURL
}

async createAccount(formValues: any) { // eslint-disable-line @typescript-eslint/no-explicit-any
console.log({ formValues })
const logo = this.draftLogoURL
const newAccountDict = Object.assign(formValues, { logo, active: true })
try {
const account = Account2FA.fromDictionary(newAccountDict)
console.log({ account2fa: account })
this.dismiss(account)
} catch (error: any) { // eslint-disable-line @typescript-eslint/no-explicit-any
const message = await firstValueFrom(this.translateService.get('ADD_ACCOUNT_MODAL.ERROR_MSGS.INVALID_FIELDS'))
console.error("Error adding account", error)
await this.showErrorToast(message)
}
}

private async processQRCode(evt: string) {
// The URI format and params is described in https://github.com/google/google-authenticator/wiki/Key-Uri-Format
// otpauth://totp/ACME%20Co:john.doe@email.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30
try {
const account = Account2FA.fromOTPAuthURL(evt)
console.log({ account })

this.validations_form.controls['label'].setValue(account.label)
this.validations_form.controls['secret'].setValue(account.secret)
this.validations_form.controls['tokenLength'].setValue(account.tokenLength)
this.validations_form.controls['interval'].setValue(account.interval)
// service name inferred from issuer or label
const serviceName = account.issuer || account.label.split(':')[0]
const event = new CustomEvent('search', { detail: { value: serviceName } }) as SearchbarCustomEvent
this.handleSearchLogo(event)
} catch (error) {
const message = await firstValueFrom(this.translateService.get('ADD_ACCOUNT_MODAL.ERROR_MSGS.INVALID_QR_CODE'))
console.error("Error processing QR code", error)
await this.showErrorToast(message)
}
}

private async showInfoToast(message: string) {
const toast = await this.toastController.create({
message,
duration: 2000,
})
await toast.present()
}

private async showErrorToast(message: string) {
const toast = await this.toastController.create({
message,
duration: 2000,
color: 'danger',
position: 'middle'
})
await toast.present()
}

private async presentLoading(message: string): Promise<void> {
// if loading is already present, update message
if(this.loading != undefined) {
this.loading.message = message
return
}

// create new loading
this.loading = await this.loadingController.create({
message,
backdropDismiss: false
})
await this.loading.present()
}

private async dismissLoading(): Promise<void> {
await this.loading?.dismiss()
this.loading = undefined
}

private async showAlert(message: string, header: string) {
const alert = await this.alertController.create({
header,
message,
buttons: ['OK']
})
await alert.present()
}
}
Loading

0 comments on commit eb2593a

Please sign in to comment.