diff --git a/angular/src/components/modal.component.ts b/angular/src/components/modal.component.ts deleted file mode 100644 index 9e4e85b88b..0000000000 --- a/angular/src/components/modal.component.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { - Component, - ComponentFactoryResolver, - EventEmitter, - OnDestroy, - Output, - Type, - ViewChild, - ViewContainerRef, -} from '@angular/core'; - -import { MessagingService } from 'jslib-common/abstractions/messaging.service'; - -@Component({ - selector: 'app-modal', - template: ``, -}) -export class ModalComponent implements OnDestroy { - @Output() onClose = new EventEmitter(); - @Output() onClosed = new EventEmitter(); - @Output() onShow = new EventEmitter(); - @Output() onShown = new EventEmitter(); - @ViewChild('container', { read: ViewContainerRef, static: true }) container: ViewContainerRef; - parentContainer: ViewContainerRef = null; - fade: boolean = true; - - constructor(protected componentFactoryResolver: ComponentFactoryResolver, - protected messagingService: MessagingService) { } - - ngOnDestroy() { - document.body.classList.remove('modal-open'); - document.body.removeChild(document.querySelector('.modal-backdrop')); - } - - show(type: Type, parentContainer: ViewContainerRef, fade: boolean = true, - setComponentParameters: (component: T) => void = null): T { - this.onShow.emit(); - this.messagingService.send('modalShow'); - this.parentContainer = parentContainer; - this.fade = fade; - - document.body.classList.add('modal-open'); - const backdrop = document.createElement('div'); - backdrop.className = 'modal-backdrop' + (this.fade ? ' fade' : ''); - document.body.appendChild(backdrop); - - const factory = this.componentFactoryResolver.resolveComponentFactory(type); - const componentRef = this.container.createComponent(factory); - if (setComponentParameters != null) { - setComponentParameters(componentRef.instance); - } - - document.querySelector('.modal-dialog').addEventListener('click', (e: Event) => { - e.stopPropagation(); - }); - - const modals = Array.from(document.querySelectorAll('.modal, .modal *[data-dismiss="modal"]')); - for (const closeElement of modals) { - closeElement.addEventListener('click', event => { - this.close(); - }); - } - - this.onShown.emit(); - this.messagingService.send('modalShown'); - return componentRef.instance; - } - - close() { - this.onClose.emit(); - this.messagingService.send('modalClose'); - this.onClosed.emit(); - this.messagingService.send('modalClosed'); - if (this.parentContainer != null) { - this.parentContainer.clear(); - } - } -} diff --git a/angular/src/components/modal/dynamic-modal.component.ts b/angular/src/components/modal/dynamic-modal.component.ts new file mode 100644 index 0000000000..d6deb99ded --- /dev/null +++ b/angular/src/components/modal/dynamic-modal.component.ts @@ -0,0 +1,57 @@ +import { + AfterViewInit, + ChangeDetectorRef, + Component, + ComponentFactoryResolver, + ComponentRef, + ElementRef, + OnDestroy, + Type, + ViewChild, + ViewContainerRef +} from '@angular/core'; + +import { ModalRef } from './modal.ref'; + +@Component({ + selector: 'app-modal', + template: '', +}) +export class DynamicModalComponent implements AfterViewInit, OnDestroy { + componentRef: ComponentRef; + + @ViewChild('modalContent', { read: ViewContainerRef, static: true }) modalContentRef: ViewContainerRef; + + childComponentType: Type; + setComponentParameters: (component: any) => void; + + constructor(private componentFactoryResolver: ComponentFactoryResolver, private cd: ChangeDetectorRef, + private el: ElementRef, public modalRef: ModalRef) {} + + ngAfterViewInit() { + this.loadChildComponent(this.childComponentType); + if (this.setComponentParameters != null) { + this.setComponentParameters(this.componentRef.instance); + } + this.cd.detectChanges(); + + this.modalRef.created(this.el.nativeElement); + } + + loadChildComponent(componentType: Type) { + const componentFactory = this.componentFactoryResolver.resolveComponentFactory(componentType); + + this.modalContentRef.clear(); + this.componentRef = this.modalContentRef.createComponent(componentFactory); + } + + ngOnDestroy() { + if (this.componentRef) { + this.componentRef.destroy(); + } + } + + close() { + this.modalRef.close(); + } +} diff --git a/angular/src/components/modal/modal-injector.ts b/angular/src/components/modal/modal-injector.ts new file mode 100644 index 0000000000..8477608e81 --- /dev/null +++ b/angular/src/components/modal/modal-injector.ts @@ -0,0 +1,15 @@ +import { + InjectFlags, + InjectionToken, + Injector, + Type +} from '@angular/core'; + +export class ModalInjector implements Injector { + constructor(private _parentInjector: Injector, private _additionalTokens: WeakMap) {} + + get(token: Type | InjectionToken, notFoundValue?: T, flags?: InjectFlags): T; + get(token: any, notFoundValue?: any, flags?: any) { + return this._additionalTokens.get(token) ?? this._parentInjector.get(token, notFoundValue); + } +} diff --git a/angular/src/components/modal/modal.ref.ts b/angular/src/components/modal/modal.ref.ts new file mode 100644 index 0000000000..116a6057c8 --- /dev/null +++ b/angular/src/components/modal/modal.ref.ts @@ -0,0 +1,51 @@ +import { Observable, Subject } from 'rxjs'; +import { first } from 'rxjs/operators'; + +export class ModalRef { + + onCreated: Observable; // Modal added to the DOM. + onClose: Observable; // Initiated close. + onClosed: Observable; // Modal was closed (Remove element from DOM) + onShow: Observable; // Start showing modal + onShown: Observable; // Modal is fully visible + + private readonly _onCreated = new Subject(); + private readonly _onClose = new Subject(); + private readonly _onClosed = new Subject(); + private readonly _onShow = new Subject(); + private readonly _onShown = new Subject(); + private lastResult: any; + + constructor() { + this.onCreated = this._onCreated.asObservable(); + this.onClose = this._onClose.asObservable(); + this.onClosed = this._onClosed.asObservable(); + this.onShow = this._onShow.asObservable(); + this.onShown = this._onShow.asObservable(); + } + + show() { + this._onShow.next(); + } + + shown() { + this._onShown.next(); + } + + close(result?: any) { + this.lastResult = result; + this._onClose.next(result); + } + + closed() { + this._onClosed.next(this.lastResult); + } + + created(el: HTMLElement) { + this._onCreated.next(el); + } + + onClosedPromise(): Promise { + return this.onClosed.pipe(first()).toPromise(); + } +} diff --git a/angular/src/components/password-reprompt.component.ts b/angular/src/components/password-reprompt.component.ts new file mode 100644 index 0000000000..688453942a --- /dev/null +++ b/angular/src/components/password-reprompt.component.ts @@ -0,0 +1,30 @@ +import { Directive } from '@angular/core'; + +import { CryptoService } from 'jslib-common/abstractions/crypto.service'; +import { I18nService } from 'jslib-common/abstractions/i18n.service'; +import { PlatformUtilsService } from 'jslib-common/abstractions/platformUtils.service'; +import { ModalRef } from './modal/modal.ref'; + +@Directive() +export class PasswordRepromptComponent { + + showPassword = false; + masterPassword = ''; + + constructor(private modalRef: ModalRef, private cryptoService: CryptoService, private platformUtilsService: PlatformUtilsService, + private i18nService: I18nService) {} + + togglePassword() { + this.showPassword = !this.showPassword; + } + + async submit() { + if (!await this.cryptoService.compareAndUpdateKeyHash(this.masterPassword, null)) { + this.platformUtilsService.showToast('error', this.i18nService.t('errorOccurred'), + this.i18nService.t('invalidMasterPassword')); + return; + } + + this.modalRef.close(true); + } +} diff --git a/angular/src/services/modal.service.ts b/angular/src/services/modal.service.ts new file mode 100644 index 0000000000..f5573b4013 --- /dev/null +++ b/angular/src/services/modal.service.ts @@ -0,0 +1,132 @@ +import { + ApplicationRef, + ComponentFactoryResolver, + ComponentRef, + EmbeddedViewRef, + Injectable, + Injector, + Type, + ViewContainerRef +} from '@angular/core'; +import { first } from 'rxjs/operators'; + +import { DynamicModalComponent } from '../components/modal/dynamic-modal.component'; +import { ModalInjector } from '../components/modal/modal-injector'; +import { ModalRef } from '../components/modal/modal.ref'; + +export class ModalConfig { + data?: D; + allowMultipleModals: boolean = false; +} + +@Injectable() +export class ModalService { + protected modalCount = 0; + + constructor(private componentFactoryResolver: ComponentFactoryResolver, private applicationRef: ApplicationRef, + private injector: Injector) {} + + async openViewRef(componentType: Type, viewContainerRef: ViewContainerRef, + setComponentParameters: (component: T) => void = null): Promise<[ModalRef, T]> { + + this.modalCount++; + const [modalRef, modalComponentRef] = this.openInternal(componentType, null, false); + modalComponentRef.instance.setComponentParameters = setComponentParameters; + + viewContainerRef.insert(modalComponentRef.hostView); + + await modalRef.onCreated.pipe(first()).toPromise(); + + return [modalRef, modalComponentRef.instance.componentRef.instance]; + } + + open(componentType: Type, config?: ModalConfig) { + if (!(config?.allowMultipleModals ?? false) && this.modalCount > 0) { + return; + } + this.modalCount++; + + const [modalRef, _] = this.openInternal(componentType, config, true); + + return modalRef; + } + + protected openInternal(componentType: Type, config?: ModalConfig, attachToDom?: boolean): + [ModalRef, ComponentRef] { + + const [modalRef, componentRef] = this.createModalComponent(config); + componentRef.instance.childComponentType = componentType; + + if (attachToDom) { + this.applicationRef.attachView(componentRef.hostView); + const domElem = (componentRef.hostView as EmbeddedViewRef).rootNodes[0] as HTMLElement; + document.body.appendChild(domElem); + } + + modalRef.onClosed.pipe(first()).subscribe(() => { + if (attachToDom) { + this.applicationRef.detachView(componentRef.hostView); + } + componentRef.destroy(); + this.modalCount--; + }); + + this.setupHandlers(modalRef); + + return [modalRef, componentRef]; + } + + protected setupHandlers(modalRef: ModalRef) { + let backdrop: HTMLElement = null; + + // Add backdrop, setup [data-dismiss] handler. + modalRef.onCreated.pipe(first()).subscribe(el => { + document.body.classList.add('modal-open'); + + backdrop = document.createElement('div'); + backdrop.className = 'modal-backdrop fade'; + backdrop.style.zIndex = `${this.modalCount}040`; + document.body.appendChild(backdrop); + + el.querySelector('.modal-dialog').addEventListener('click', (e: Event) => { + e.stopPropagation(); + }); + + const modalEl: HTMLElement = el.querySelector('.modal'); + modalEl.style.zIndex = `${this.modalCount}050`; + + const modals = Array.from(el.querySelectorAll('.modal, .modal *[data-dismiss="modal"]')); + for (const closeElement of modals) { + closeElement.addEventListener('click', event => { + modalRef.close(); + }); + } + }); + + // onClose is used in Web to hook into bootstrap. On other projects we pipe it directly to closed. + modalRef.onClose.pipe(first()).subscribe(() => { + modalRef.closed(); + + if (this.modalCount === 0) { + document.body.classList.remove('modal-open'); + } + + if (backdrop != null) { + document.body.removeChild(backdrop); + } + }); + } + + protected createModalComponent(config: ModalConfig): [ModalRef, ComponentRef] { + const modalRef = new ModalRef(); + + const map = new WeakMap(); + map.set(ModalConfig, config); + map.set(ModalRef, modalRef); + + const componentFactory = this.componentFactoryResolver.resolveComponentFactory(DynamicModalComponent); + const componentRef = componentFactory.create(new ModalInjector(this.injector, map)); + + return [modalRef, componentRef]; + } +} diff --git a/angular/src/services/passwordReprompt.service.ts b/angular/src/services/passwordReprompt.service.ts new file mode 100644 index 0000000000..552e558b46 --- /dev/null +++ b/angular/src/services/passwordReprompt.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@angular/core'; + +import { PasswordRepromptService as PasswordRepromptServiceAbstraction } from 'jslib-common/abstractions/passwordReprompt.service'; + +import { PasswordRepromptComponent } from '../components/password-reprompt.component'; +import { ModalService } from './modal.service'; + +@Injectable() +export class PasswordRepromptService implements PasswordRepromptServiceAbstraction { + protected component = PasswordRepromptComponent; + + constructor(private modalService: ModalService) { } + + protectedFields() { + return ['TOTP', 'Password', 'H_Field', 'Card Number', 'Security Code']; + } + + async showPasswordPrompt() { + const ref = this.modalService.open(this.component, {allowMultipleModals: true}); + + if (ref == null) { + return false; + } + + const result = await ref.onClosedPromise(); + return result === true; + } +} diff --git a/common/src/abstractions/platformUtils.service.ts b/common/src/abstractions/platformUtils.service.ts index 264697aa8b..8828d3af7b 100644 --- a/common/src/abstractions/platformUtils.service.ts +++ b/common/src/abstractions/platformUtils.service.ts @@ -26,7 +26,6 @@ export abstract class PlatformUtilsService { options?: any) => void; showDialog: (body: string, title?: string, confirmText?: string, cancelText?: string, type?: string, bodyIsHtml?: boolean) => Promise; - showPasswordDialog: (title: string, body: string, passwordValidation: (value: string) => Promise) => Promise; isDev: () => boolean; isSelfHost: () => boolean; copyToClipboard: (text: string, options?: any) => void | boolean; diff --git a/common/src/services/passwordReprompt.service.ts b/common/src/services/passwordReprompt.service.ts deleted file mode 100644 index ae3e1a5120..0000000000 --- a/common/src/services/passwordReprompt.service.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { PlatformUtilsService } from '../abstractions'; - -import { CryptoService } from '../abstractions/crypto.service'; -import { I18nService } from '../abstractions/i18n.service'; -import { PasswordRepromptService as PasswordRepromptServiceAbstraction } from '../abstractions/passwordReprompt.service'; - -import { HashPurpose } from '../enums/hashPurpose'; - -export class PasswordRepromptService implements PasswordRepromptServiceAbstraction { - constructor(private i18nService: I18nService, private cryptoService: CryptoService, - private platformUtilService: PlatformUtilsService) { } - - protectedFields() { - return ['TOTP', 'Password', 'H_Field', 'Card Number', 'Security Code']; - } - - async showPasswordPrompt() { - const passwordValidator = (value: string) => { - return this.cryptoService.compareAndUpdateKeyHash(value, null); - }; - - return this.platformUtilService.showPasswordDialog(this.i18nService.t('passwordConfirmation'), this.i18nService.t('passwordConfirmationDesc'), passwordValidator); - } -} diff --git a/electron/src/services/electronPlatformUtils.service.ts b/electron/src/services/electronPlatformUtils.service.ts index 36da32cc6e..87f719defd 100644 --- a/electron/src/services/electronPlatformUtils.service.ts +++ b/electron/src/services/electronPlatformUtils.service.ts @@ -151,11 +151,6 @@ export class ElectronPlatformUtilsService implements PlatformUtilsService { return Promise.resolve(result.response === 0); } - async showPasswordDialog(title: string, body: string, passwordValidation: (value: string) => Promise): - Promise { - throw new Error('Not implemented.'); - } - isDev(): boolean { return isDev(); } diff --git a/node/src/cli/services/cliPlatformUtils.service.ts b/node/src/cli/services/cliPlatformUtils.service.ts index e606c6137b..fe2af22c4e 100644 --- a/node/src/cli/services/cliPlatformUtils.service.ts +++ b/node/src/cli/services/cliPlatformUtils.service.ts @@ -114,11 +114,6 @@ export class CliPlatformUtilsService implements PlatformUtilsService { throw new Error('Not implemented.'); } - showPasswordDialog(title: string, body: string, passwordValidation: (value: string) => Promise): - Promise { - throw new Error('Not implemented.'); - } - isDev(): boolean { return process.env.BWCLI_ENV === 'development'; }