mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-23 11:56:00 +01:00
Dynamic Modals (#417)
* Move backdrop and click handler to modal service since they should not be used in web * Add support for opening modals using ViewContainerRef
This commit is contained in:
parent
add4b2f3e9
commit
daa4f6f9a6
@ -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: `<ng-template #container></ng-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<T>(type: Type<T>, 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<T>(type);
|
||||
const componentRef = this.container.createComponent<T>(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();
|
||||
}
|
||||
}
|
||||
}
|
57
angular/src/components/modal/dynamic-modal.component.ts
Normal file
57
angular/src/components/modal/dynamic-modal.component.ts
Normal file
@ -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: '<ng-template #modalContent></ng-template>',
|
||||
})
|
||||
export class DynamicModalComponent implements AfterViewInit, OnDestroy {
|
||||
componentRef: ComponentRef<any>;
|
||||
|
||||
@ViewChild('modalContent', { read: ViewContainerRef, static: true }) modalContentRef: ViewContainerRef;
|
||||
|
||||
childComponentType: Type<any>;
|
||||
setComponentParameters: (component: any) => void;
|
||||
|
||||
constructor(private componentFactoryResolver: ComponentFactoryResolver, private cd: ChangeDetectorRef,
|
||||
private el: ElementRef<HTMLElement>, 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<any>) {
|
||||
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();
|
||||
}
|
||||
}
|
15
angular/src/components/modal/modal-injector.ts
Normal file
15
angular/src/components/modal/modal-injector.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import {
|
||||
InjectFlags,
|
||||
InjectionToken,
|
||||
Injector,
|
||||
Type
|
||||
} from '@angular/core';
|
||||
|
||||
export class ModalInjector implements Injector {
|
||||
constructor(private _parentInjector: Injector, private _additionalTokens: WeakMap<any, any>) {}
|
||||
|
||||
get<T>(token: Type<T> | InjectionToken<T>, notFoundValue?: T, flags?: InjectFlags): T;
|
||||
get(token: any, notFoundValue?: any, flags?: any) {
|
||||
return this._additionalTokens.get(token) ?? this._parentInjector.get<any>(token, notFoundValue);
|
||||
}
|
||||
}
|
51
angular/src/components/modal/modal.ref.ts
Normal file
51
angular/src/components/modal/modal.ref.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
import { first } from 'rxjs/operators';
|
||||
|
||||
export class ModalRef {
|
||||
|
||||
onCreated: Observable<HTMLElement>; // Modal added to the DOM.
|
||||
onClose: Observable<any>; // Initiated close.
|
||||
onClosed: Observable<any>; // Modal was closed (Remove element from DOM)
|
||||
onShow: Observable<any>; // Start showing modal
|
||||
onShown: Observable<any>; // Modal is fully visible
|
||||
|
||||
private readonly _onCreated = new Subject<HTMLElement>();
|
||||
private readonly _onClose = new Subject<any>();
|
||||
private readonly _onClosed = new Subject<any>();
|
||||
private readonly _onShow = new Subject<any>();
|
||||
private readonly _onShown = new Subject<any>();
|
||||
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<any> {
|
||||
return this.onClosed.pipe(first()).toPromise();
|
||||
}
|
||||
}
|
30
angular/src/components/password-reprompt.component.ts
Normal file
30
angular/src/components/password-reprompt.component.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
132
angular/src/services/modal.service.ts
Normal file
132
angular/src/services/modal.service.ts
Normal file
@ -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<D = any> {
|
||||
data?: D;
|
||||
allowMultipleModals: boolean = false;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ModalService {
|
||||
protected modalCount = 0;
|
||||
|
||||
constructor(private componentFactoryResolver: ComponentFactoryResolver, private applicationRef: ApplicationRef,
|
||||
private injector: Injector) {}
|
||||
|
||||
async openViewRef<T>(componentType: Type<T>, 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<any>, 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<any>, config?: ModalConfig, attachToDom?: boolean):
|
||||
[ModalRef, ComponentRef<DynamicModalComponent>] {
|
||||
|
||||
const [modalRef, componentRef] = this.createModalComponent(config);
|
||||
componentRef.instance.childComponentType = componentType;
|
||||
|
||||
if (attachToDom) {
|
||||
this.applicationRef.attachView(componentRef.hostView);
|
||||
const domElem = (componentRef.hostView as EmbeddedViewRef<any>).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<DynamicModalComponent>] {
|
||||
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];
|
||||
}
|
||||
}
|
28
angular/src/services/passwordReprompt.service.ts
Normal file
28
angular/src/services/passwordReprompt.service.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
@ -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<boolean>;
|
||||
showPasswordDialog: (title: string, body: string, passwordValidation: (value: string) => Promise<boolean>) => Promise<boolean>;
|
||||
isDev: () => boolean;
|
||||
isSelfHost: () => boolean;
|
||||
copyToClipboard: (text: string, options?: any) => void | boolean;
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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<boolean>):
|
||||
Promise<boolean> {
|
||||
throw new Error('Not implemented.');
|
||||
}
|
||||
|
||||
isDev(): boolean {
|
||||
return isDev();
|
||||
}
|
||||
|
@ -114,11 +114,6 @@ export class CliPlatformUtilsService implements PlatformUtilsService {
|
||||
throw new Error('Not implemented.');
|
||||
}
|
||||
|
||||
showPasswordDialog(title: string, body: string, passwordValidation: (value: string) => Promise<boolean>):
|
||||
Promise<boolean> {
|
||||
throw new Error('Not implemented.');
|
||||
}
|
||||
|
||||
isDev(): boolean {
|
||||
return process.env.BWCLI_ENV === 'development';
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user