mirror of
https://github.com/bitwarden/jslib
synced 2025-12-06 00:03:29 +00: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:
@@ -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;
|
options?: any) => void;
|
||||||
showDialog: (body: string, title?: string, confirmText?: string, cancelText?: string,
|
showDialog: (body: string, title?: string, confirmText?: string, cancelText?: string,
|
||||||
type?: string, bodyIsHtml?: boolean) => Promise<boolean>;
|
type?: string, bodyIsHtml?: boolean) => Promise<boolean>;
|
||||||
showPasswordDialog: (title: string, body: string, passwordValidation: (value: string) => Promise<boolean>) => Promise<boolean>;
|
|
||||||
isDev: () => boolean;
|
isDev: () => boolean;
|
||||||
isSelfHost: () => boolean;
|
isSelfHost: () => boolean;
|
||||||
copyToClipboard: (text: string, options?: any) => void | 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);
|
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 {
|
isDev(): boolean {
|
||||||
return isDev();
|
return isDev();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,11 +114,6 @@ export class CliPlatformUtilsService implements PlatformUtilsService {
|
|||||||
throw new Error('Not implemented.');
|
throw new Error('Not implemented.');
|
||||||
}
|
}
|
||||||
|
|
||||||
showPasswordDialog(title: string, body: string, passwordValidation: (value: string) => Promise<boolean>):
|
|
||||||
Promise<boolean> {
|
|
||||||
throw new Error('Not implemented.');
|
|
||||||
}
|
|
||||||
|
|
||||||
isDev(): boolean {
|
isDev(): boolean {
|
||||||
return process.env.BWCLI_ENV === 'development';
|
return process.env.BWCLI_ENV === 'development';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user