import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit, ViewChild, ViewContainerRef, } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; import { first } from "rxjs/operators"; import { ModalRef } from "jslib-angular/components/modal/modal.ref"; import { ModalService } from "jslib-angular/services/modal.service"; import { BroadcasterService } from "jslib-common/abstractions/broadcaster.service"; import { EventService } from "jslib-common/abstractions/event.service"; import { I18nService } from "jslib-common/abstractions/i18n.service"; import { MessagingService } from "jslib-common/abstractions/messaging.service"; import { PasswordRepromptService } from "jslib-common/abstractions/passwordReprompt.service"; import { PlatformUtilsService } from "jslib-common/abstractions/platformUtils.service"; import { StateService } from "jslib-common/abstractions/state.service"; import { SyncService } from "jslib-common/abstractions/sync.service"; import { TotpService } from "jslib-common/abstractions/totp.service"; import { CipherRepromptType } from "jslib-common/enums/cipherRepromptType"; import { CipherType } from "jslib-common/enums/cipherType"; import { EventType } from "jslib-common/enums/eventType"; import { CipherView } from "jslib-common/models/view/cipherView"; import { FolderView } from "jslib-common/models/view/folderView"; import { invokeMenu, RendererMenuItem } from "jslib-electron/utils"; import { SearchBarService } from "../layout/search/search-bar.service"; import { AddEditComponent } from "./add-edit.component"; import { AttachmentsComponent } from "./attachments.component"; import { CiphersComponent } from "./ciphers.component"; import { CollectionsComponent } from "./collections.component"; import { FolderAddEditComponent } from "./folder-add-edit.component"; import { GroupingsComponent } from "./groupings.component"; import { PasswordGeneratorComponent } from "./password-generator.component"; import { PasswordHistoryComponent } from "./password-history.component"; import { ShareComponent } from "./share.component"; import { ViewComponent } from "./view.component"; const BroadcasterSubscriptionId = "VaultComponent"; @Component({ selector: "app-vault", templateUrl: "vault.component.html", }) export class VaultComponent implements OnInit, OnDestroy { @ViewChild(ViewComponent) viewComponent: ViewComponent; @ViewChild(AddEditComponent) addEditComponent: AddEditComponent; @ViewChild(CiphersComponent, { static: true }) ciphersComponent: CiphersComponent; @ViewChild(GroupingsComponent, { static: true }) groupingsComponent: GroupingsComponent; @ViewChild("passwordGenerator", { read: ViewContainerRef, static: true }) passwordGeneratorModalRef: ViewContainerRef; @ViewChild("attachments", { read: ViewContainerRef, static: true }) attachmentsModalRef: ViewContainerRef; @ViewChild("passwordHistory", { read: ViewContainerRef, static: true }) passwordHistoryModalRef: ViewContainerRef; @ViewChild("share", { read: ViewContainerRef, static: true }) shareModalRef: ViewContainerRef; @ViewChild("collections", { read: ViewContainerRef, static: true }) collectionsModalRef: ViewContainerRef; @ViewChild("folderAddEdit", { read: ViewContainerRef, static: true }) folderAddEditModalRef: ViewContainerRef; action: string; cipherId: string = null; favorites = false; type: CipherType = null; folderId: string = null; collectionId: string = null; addType: CipherType = null; addOrganizationId: string = null; addCollectionIds: string[] = null; showingModal = false; deleted = false; userHasPremiumAccess = false; private modal: ModalRef = null; constructor( private route: ActivatedRoute, private router: Router, private i18nService: I18nService, private modalService: ModalService, private broadcasterService: BroadcasterService, private changeDetectorRef: ChangeDetectorRef, private ngZone: NgZone, private syncService: SyncService, private messagingService: MessagingService, private platformUtilsService: PlatformUtilsService, private eventService: EventService, private totpService: TotpService, private passwordRepromptService: PasswordRepromptService, private stateService: StateService, private searchBarService: SearchBarService ) {} async ngOnInit() { this.userHasPremiumAccess = await this.stateService.getCanAccessPremium(); this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => { this.ngZone.run(async () => { let detectChanges = true; switch (message.command) { case "newLogin": await this.addCipher(CipherType.Login); break; case "newCard": await this.addCipher(CipherType.Card); break; case "newIdentity": await this.addCipher(CipherType.Identity); break; case "newSecureNote": await this.addCipher(CipherType.SecureNote); break; case "focusSearch": (document.querySelector("#search") as HTMLInputElement).select(); detectChanges = false; break; case "openPasswordGenerator": await this.openPasswordGenerator(false); break; case "syncCompleted": await this.load(); break; case "refreshCiphers": this.ciphersComponent.refresh(); break; case "modalShown": this.showingModal = true; break; case "modalClosed": this.showingModal = false; break; case "copyUsername": { const uComponent = this.addEditComponent == null ? this.viewComponent : this.addEditComponent; const uCipher = uComponent != null ? uComponent.cipher : null; if ( this.cipherId != null && uCipher != null && uCipher.id === this.cipherId && uCipher.login != null && uCipher.login.username != null ) { this.copyValue(uCipher, uCipher.login.username, "username", "Username"); } break; } case "copyPassword": { const pComponent = this.addEditComponent == null ? this.viewComponent : this.addEditComponent; const pCipher = pComponent != null ? pComponent.cipher : null; if ( this.cipherId != null && pCipher != null && pCipher.id === this.cipherId && pCipher.login != null && pCipher.login.password != null && pCipher.viewPassword ) { this.copyValue(pCipher, pCipher.login.password, "password", "Password"); } break; } case "copyTotp": { const tComponent = this.addEditComponent == null ? this.viewComponent : this.addEditComponent; const tCipher = tComponent != null ? tComponent.cipher : null; if ( this.cipherId != null && tCipher != null && tCipher.id === this.cipherId && tCipher.login != null && tCipher.login.hasTotp && this.userHasPremiumAccess ) { const value = await this.totpService.getCode(tCipher.login.totp); this.copyValue(tCipher, value, "verificationCodeTotp", "TOTP"); } break; } default: detectChanges = false; break; } if (detectChanges) { this.changeDetectorRef.detectChanges(); } }); }); if (!this.syncService.syncInProgress) { await this.load(); } document.body.classList.remove("layout_frontend"); this.searchBarService.setEnabled(true); this.searchBarService.setPlaceholderText(this.i18nService.t("searchVault")); } ngOnDestroy() { this.searchBarService.setEnabled(false); this.broadcasterService.unsubscribe(BroadcasterSubscriptionId); document.body.classList.add("layout_frontend"); } async load() { this.route.queryParams.pipe(first()).subscribe(async (params) => { await this.groupingsComponent.load(); if (params == null) { this.groupingsComponent.selectedAll = true; await this.ciphersComponent.reload(); } else { if (params.cipherId) { const cipherView = new CipherView(); cipherView.id = params.cipherId; if (params.action === "clone") { await this.cloneCipher(cipherView); } else if (params.action === "edit") { await this.editCipher(cipherView); } else { await this.viewCipher(cipherView); } } else if (params.action === "add") { this.addType = Number(params.addType); this.addCipher(this.addType); } if (params.deleted) { this.groupingsComponent.selectedTrash = true; await this.filterDeleted(); } else if (params.favorites) { this.groupingsComponent.selectedFavorites = true; await this.filterFavorites(); } else if (params.type && params.action !== "add") { const t = parseInt(params.type, null); this.groupingsComponent.selectedType = t; await this.filterCipherType(t); } else if (params.folderId) { this.groupingsComponent.selectedFolder = true; this.groupingsComponent.selectedFolderId = params.folderId; await this.filterFolder(params.folderId); } else if (params.collectionId) { this.groupingsComponent.selectedCollectionId = params.collectionId; await this.filterCollection(params.collectionId); } else { this.groupingsComponent.selectedAll = true; await this.ciphersComponent.reload(); } } }); } async viewCipher(cipher: CipherView) { if (!(await this.canNavigateAway("view", cipher))) { return; } this.cipherId = cipher.id; this.action = "view"; this.go(); } viewCipherMenu(cipher: CipherView) { const menu: RendererMenuItem[] = [ { label: this.i18nService.t("view"), click: () => this.functionWithChangeDetection(() => { this.viewCipher(cipher); }), }, ]; if (!cipher.isDeleted) { menu.push({ label: this.i18nService.t("edit"), click: () => this.functionWithChangeDetection(() => { this.editCipher(cipher); }), }); menu.push({ label: this.i18nService.t("clone"), click: () => this.functionWithChangeDetection(() => { this.cloneCipher(cipher); }), }); } switch (cipher.type) { case CipherType.Login: if ( cipher.login.canLaunch || cipher.login.username != null || cipher.login.password != null ) { menu.push({ type: "separator" }); } if (cipher.login.canLaunch) { menu.push({ label: this.i18nService.t("launch"), click: () => this.platformUtilsService.launchUri(cipher.login.launchUri), }); } if (cipher.login.username != null) { menu.push({ label: this.i18nService.t("copyUsername"), click: () => this.copyValue(cipher, cipher.login.username, "username", "Username"), }); } if (cipher.login.password != null && cipher.viewPassword) { menu.push({ label: this.i18nService.t("copyPassword"), click: () => { this.copyValue(cipher, cipher.login.password, "password", "Password"); this.eventService.collect(EventType.Cipher_ClientCopiedPassword, cipher.id); }, }); } if (cipher.login.hasTotp && (cipher.organizationUseTotp || this.userHasPremiumAccess)) { menu.push({ label: this.i18nService.t("copyVerificationCodeTotp"), click: async () => { const value = await this.totpService.getCode(cipher.login.totp); this.copyValue(cipher, value, "verificationCodeTotp", "TOTP"); }, }); } break; case CipherType.Card: if (cipher.card.number != null || cipher.card.code != null) { menu.push({ type: "separator" }); } if (cipher.card.number != null) { menu.push({ label: this.i18nService.t("copyNumber"), click: () => this.copyValue(cipher, cipher.card.number, "number", "Card Number"), }); } if (cipher.card.code != null) { menu.push({ label: this.i18nService.t("copySecurityCode"), click: () => { this.copyValue(cipher, cipher.card.code, "securityCode", "Security Code"); this.eventService.collect(EventType.Cipher_ClientCopiedCardCode, cipher.id); }, }); } break; default: break; } invokeMenu(menu); } async editCipher(cipher: CipherView) { if (!(await this.canNavigateAway("edit", cipher))) { return; } else if (!(await this.passwordReprompt(cipher))) { return; } await this.editCipherWithoutPasswordPrompt(cipher); } async editCipherWithoutPasswordPrompt(cipher: CipherView) { if (!(await this.canNavigateAway("edit", cipher))) { return; } this.cipherId = cipher.id; this.action = "edit"; this.go(); } async cloneCipher(cipher: CipherView) { if (!(await this.canNavigateAway("clone", cipher))) { return; } else if (!(await this.passwordReprompt(cipher))) { return; } await this.cloneCipherWithoutPasswordPrompt(cipher); } async cloneCipherWithoutPasswordPrompt(cipher: CipherView) { if (!(await this.canNavigateAway("edit", cipher))) { return; } this.cipherId = cipher.id; this.action = "clone"; this.go(); } async addCipher(type: CipherType = null) { if (!(await this.canNavigateAway("add", null))) { return; } this.addType = type; this.action = "add"; this.cipherId = null; this.updateCollectionProperties(); this.go(); } addCipherOptions() { const menu: RendererMenuItem[] = [ { label: this.i18nService.t("typeLogin"), click: () => this.addCipherWithChangeDetection(CipherType.Login), }, { label: this.i18nService.t("typeCard"), click: () => this.addCipherWithChangeDetection(CipherType.Card), }, { label: this.i18nService.t("typeIdentity"), click: () => this.addCipherWithChangeDetection(CipherType.Identity), }, { label: this.i18nService.t("typeSecureNote"), click: () => this.addCipherWithChangeDetection(CipherType.SecureNote), }, ]; invokeMenu(menu); } async savedCipher(cipher: CipherView) { this.cipherId = cipher.id; this.action = "view"; this.go(); await this.ciphersComponent.refresh(); } async deletedCipher(cipher: CipherView) { this.cipherId = null; this.action = null; this.go(); await this.ciphersComponent.refresh(); } async restoredCipher(cipher: CipherView) { this.cipherId = null; this.action = null; this.go(); await this.ciphersComponent.refresh(); } async editCipherAttachments(cipher: CipherView) { if (this.modal != null) { this.modal.close(); } const [modal, childComponent] = await this.modalService.openViewRef( AttachmentsComponent, this.attachmentsModalRef, (comp) => (comp.cipherId = cipher.id) ); this.modal = modal; let madeAttachmentChanges = false; childComponent.onUploadedAttachment.subscribe(() => (madeAttachmentChanges = true)); childComponent.onDeletedAttachment.subscribe(() => (madeAttachmentChanges = true)); this.modal.onClosed.subscribe(async () => { this.modal = null; if (madeAttachmentChanges) { await this.ciphersComponent.refresh(); } madeAttachmentChanges = false; }); } async shareCipher(cipher: CipherView) { if (this.modal != null) { this.modal.close(); } const [modal, childComponent] = await this.modalService.openViewRef( ShareComponent, this.shareModalRef, (comp) => (comp.cipherId = cipher.id) ); this.modal = modal; childComponent.onSharedCipher.subscribe(async () => { this.modal.close(); this.viewCipher(cipher); await this.ciphersComponent.refresh(); }); this.modal.onClosed.subscribe(async () => { this.modal = null; }); } async cipherCollections(cipher: CipherView) { if (this.modal != null) { this.modal.close(); } const [modal, childComponent] = await this.modalService.openViewRef( CollectionsComponent, this.collectionsModalRef, (comp) => (comp.cipherId = cipher.id) ); this.modal = modal; childComponent.onSavedCollections.subscribe(() => { this.modal.close(); this.viewCipher(cipher); }); this.modal.onClosed.subscribe(async () => { this.modal = null; }); } async viewCipherPasswordHistory(cipher: CipherView) { if (this.modal != null) { this.modal.close(); } [this.modal] = await this.modalService.openViewRef( PasswordHistoryComponent, this.passwordHistoryModalRef, (comp) => (comp.cipherId = cipher.id) ); this.modal.onClosed.subscribe(async () => { this.modal = null; }); } cancelledAddEdit(cipher: CipherView) { this.cipherId = cipher.id; this.action = this.cipherId != null ? "view" : null; this.go(); } async clearGroupingFilters() { this.searchBarService.setPlaceholderText(this.i18nService.t("searchVault")); await this.ciphersComponent.reload(); this.clearFilters(); this.go(); } async filterFavorites() { this.searchBarService.setPlaceholderText(this.i18nService.t("searchFavorites")); await this.ciphersComponent.reload((c) => c.favorite); this.clearFilters(); this.favorites = true; this.go(); } async filterDeleted() { this.searchBarService.setPlaceholderText(this.i18nService.t("searchTrash")); this.ciphersComponent.deleted = true; await this.ciphersComponent.reload(null, true); this.clearFilters(); this.deleted = true; this.go(); } async filterCipherType(type: CipherType) { this.searchBarService.setPlaceholderText(this.i18nService.t("searchType")); await this.ciphersComponent.reload((c) => c.type === type); this.clearFilters(); this.type = type; this.go(); } async filterFolder(folderId: string) { folderId = folderId === "none" ? null : folderId; this.searchBarService.setPlaceholderText(this.i18nService.t("searchFolder")); await this.ciphersComponent.reload((c) => c.folderId === folderId); this.clearFilters(); this.folderId = folderId == null ? "none" : folderId; this.go(); } async filterCollection(collectionId: string) { this.searchBarService.setPlaceholderText(this.i18nService.t("searchCollection")); await this.ciphersComponent.reload( (c) => c.collectionIds != null && c.collectionIds.indexOf(collectionId) > -1 ); this.clearFilters(); this.collectionId = collectionId; this.updateCollectionProperties(); this.go(); } async openPasswordGenerator(showSelect: boolean) { if (this.modal != null) { this.modal.close(); } const [modal, childComponent] = await this.modalService.openViewRef( PasswordGeneratorComponent, this.passwordGeneratorModalRef, (comp) => (comp.showSelect = showSelect) ); this.modal = modal; childComponent.onSelected.subscribe((password: string) => { this.modal.close(); if ( this.addEditComponent != null && this.addEditComponent.cipher != null && this.addEditComponent.cipher.type === CipherType.Login && this.addEditComponent.cipher.login != null ) { this.addEditComponent.markPasswordAsDirty(); this.addEditComponent.cipher.login.password = password; } }); this.modal.onClosed.subscribe(() => { this.modal = null; }); } async addFolder() { this.messagingService.send("newFolder"); } async editFolder(folderId: string) { if (this.modal != null) { this.modal.close(); } const [modal, childComponent] = await this.modalService.openViewRef( FolderAddEditComponent, this.folderAddEditModalRef, (comp) => (comp.folderId = folderId) ); this.modal = modal; childComponent.onSavedFolder.subscribe(async (folder: FolderView) => { this.modal.close(); await this.groupingsComponent.loadFolders(); }); childComponent.onDeletedFolder.subscribe(async (folder: FolderView) => { this.modal.close(); await this.groupingsComponent.loadFolders(); }); this.modal.onClosed.subscribe(() => { this.modal = null; }); } private dirtyInput(): boolean { return ( (this.action === "add" || this.action === "edit" || this.action === "clone") && document.querySelectorAll("app-vault-add-edit .ng-dirty").length > 0 ); } private async wantsToSaveChanges(): Promise { const confirmed = await this.platformUtilsService.showDialog( this.i18nService.t("unsavedChangesConfirmation"), this.i18nService.t("unsavedChangesTitle"), this.i18nService.t("yes"), this.i18nService.t("no"), "warning" ); return !confirmed; } private clearFilters() { this.folderId = null; this.collectionId = null; this.favorites = false; this.type = null; this.addCollectionIds = null; this.addType = null; this.addOrganizationId = null; this.deleted = false; } private go(queryParams: any = null) { if (queryParams == null) { queryParams = { action: this.action, cipherId: this.cipherId, favorites: this.favorites ? true : null, type: this.type, folderId: this.folderId, collectionId: this.collectionId, deleted: this.deleted ? true : null, }; } this.router.navigate([], { relativeTo: this.route, queryParams: queryParams, replaceUrl: true, }); } private addCipherWithChangeDetection(type: CipherType = null) { this.functionWithChangeDetection(() => this.addCipher(type)); } private copyValue(cipher: CipherView, value: string, labelI18nKey: string, aType: string) { this.functionWithChangeDetection(async () => { if ( cipher.reprompt !== CipherRepromptType.None && this.passwordRepromptService.protectedFields().includes(aType) && !(await this.passwordRepromptService.showPasswordPrompt()) ) { return; } this.platformUtilsService.copyToClipboard(value); this.platformUtilsService.showToast( "info", null, this.i18nService.t("valueCopied", this.i18nService.t(labelI18nKey)) ); if (this.action === "view") { this.messagingService.send("minimizeOnCopy"); } }); } private functionWithChangeDetection(func: () => void) { this.ngZone.run(() => { func(); this.changeDetectorRef.detectChanges(); }); } private updateCollectionProperties() { if (this.collectionId != null) { const collection = this.groupingsComponent.collections.filter( (c) => c.id === this.collectionId ); if (collection.length > 0) { this.addOrganizationId = collection[0].organizationId; this.addCollectionIds = [this.collectionId]; return; } } this.addOrganizationId = null; this.addCollectionIds = null; } private async canNavigateAway(action: string, cipher?: CipherView) { // Don't navigate to same route if (this.action === action && (cipher == null || this.cipherId === cipher.id)) { return false; } else if (this.dirtyInput() && (await this.wantsToSaveChanges())) { return false; } return true; } private async passwordReprompt(cipher: CipherView) { return ( cipher.reprompt === CipherRepromptType.None || (await this.passwordRepromptService.showPasswordPrompt()) ); } }