From 3a31eb2f10f1fccef0c5807d2d64e4cd337bf881 Mon Sep 17 00:00:00 2001 From: Alec Rippberger <127791530+alec-livefront@users.noreply.github.com> Date: Mon, 19 Aug 2024 16:32:17 -0500 Subject: [PATCH] PM-9665: implement view item view (#10416) * Add initial view cipher dialog. * Add working view cipher modal dialog markup. * Cleanup dialog markup and allow edit from dialog. * Cleanup unused imports. * Begin adding org-vault view-cipher functionality. * Refactor to remove loose-components usage and use DialogService. * Add edit and delete button functionality. * Add delete functionality. * Remove addition to loose components. * Remove unused modal-dialog artifacts. * Ensure dialog closes and URL updates properly on edit or close. * Disable edit/delete buttons instead of hiding them. * Add simple tests for view.component.ts. * Adjust import order. * Remove now unnecessary ng-template. * Decrypt cipher to cipher view. * Add cleanup function and additional delete test. * Remove boolean return from delete promise. * Remove fake timers. * Remove unnecessary TestBed.inject calls. * Add code comments. * Hide new view cipher dialog behind feature flag. * Keep "else if" statement intact. * Simplify getting cipherTypeString. * Add comments to vault.component.ts files. * Change button type to "danger" Update apps/web/src/app/vault/individual-vault/view.component.html Co-authored-by: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> * Add a11y title to delete button. * Simplify OrganizationService testing. * Update comment to better reflect function. * Use large dialog to better match designs. * Add aria-haspopup to cipher row button. * Add deleteCipher to messages.json. * Remove extra argument from canEditAllCiphers. * Use 'delete' instead of 'delete cipher' for a11y title. * Remove 'bitFormButton' from non-form buttons. * Rework view cipher view delete functionality. * Add translations for cipher types. * Remove unecesarry test. * Add additional test coverage to ensure dialogs close. * Add back delete functionality in view.component.ts. * Update "secure note" to "note". --------- Co-authored-by: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> --- .../vault-cipher-row.component.html | 3 +- .../vault-items/vault-cipher-row.component.ts | 24 ++- .../vault/individual-vault/vault.component.ts | 72 ++++++- .../vault/individual-vault/vault.module.ts | 2 + .../individual-vault/view.component.html | 25 +++ .../individual-vault/view.component.spec.ts | 117 +++++++++++ .../vault/individual-vault/view.component.ts | 193 ++++++++++++++++++ .../app/vault/org-vault/vault.component.ts | 67 +++++- .../src/app/vault/org-vault/vault.module.ts | 2 + apps/web/src/locales/en/messages.json | 9 + 10 files changed, 505 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/app/vault/individual-vault/view.component.html create mode 100644 apps/web/src/app/vault/individual-vault/view.component.spec.ts create mode 100644 apps/web/src/app/vault/individual-vault/view.component.ts diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html index 0e515a307c6..524d9dff20b 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html @@ -19,11 +19,12 @@ class="tw-overflow-hidden tw-text-ellipsis tw-text-start tw-leading-snug" [disabled]="disabled" [routerLink]="[]" - [queryParams]="{ itemId: cipher.id }" + [queryParams]="{ itemId: cipher.id, action: extensionRefreshEnabled ? 'view' : null }" queryParamsHandling="merge" title="{{ 'editItemWithName' | i18n: cipher.name }}" type="button" appStopProp + aria-haspopup="true" > {{ cipher.name }} diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts index db6d04c2373..72d6a57aad4 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts @@ -1,6 +1,9 @@ -import { Component, EventEmitter, Input, Output } from "@angular/core"; +import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { firstValueFrom } from "rxjs"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; @@ -12,9 +15,14 @@ import { RowHeightClass } from "./vault-items.component"; selector: "tr[appVaultCipherRow]", templateUrl: "vault-cipher-row.component.html", }) -export class VaultCipherRowComponent { +export class VaultCipherRowComponent implements OnInit { protected RowHeightClass = RowHeightClass; + /** + * Flag to determine if the extension refresh feature flag is enabled. + */ + protected extensionRefreshEnabled = false; + @Input() disabled: boolean; @Input() cipher: CipherView; @Input() showOwner: boolean; @@ -36,6 +44,18 @@ export class VaultCipherRowComponent { protected CipherType = CipherType; + constructor(private configService: ConfigService) {} + + /** + * Lifecycle hook for component initialization. + * Checks if the extension refresh feature flag is enabled to provide to template. + */ + async ngOnInit(): Promise { + this.extensionRefreshEnabled = await firstValueFrom( + this.configService.getFeatureFlag$(FeatureFlag.ExtensionRefresh), + ); + } + protected get showTotpCopyButton() { return ( (this.cipher.login?.hasTotp ?? false) && diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index 77fd63a65f7..5a4a6794f3d 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -109,6 +109,11 @@ import { FolderFilter, OrganizationFilter } from "./vault-filter/shared/models/v import { VaultFilterModule } from "./vault-filter/vault-filter.module"; import { VaultHeaderComponent } from "./vault-header/vault-header.component"; import { VaultOnboardingComponent } from "./vault-onboarding/vault-onboarding.component"; +import { + openViewCipherDialog, + ViewCipherDialogCloseResult, + ViewCipherDialogResult, +} from "./view.component"; const BroadcasterSubscriptionId = "VaultComponent"; const SearchTextDebounceInterval = 200; @@ -215,6 +220,8 @@ export class VaultComponent implements OnInit, OnDestroy { cipherView.id = cipherId; if (params.action === "clone") { await this.cloneCipher(cipherView); + } else if (params.action === "view") { + await this.viewCipher(cipherView); } else if (params.action === "edit") { await this.editCipher(cipherView); } @@ -336,9 +343,14 @@ export class VaultComponent implements OnInit, OnDestroy { switchMap(() => this.route.queryParams), switchMap(async (params) => { const cipherId = getCipherIdFromParams(params); + if (cipherId) { - if ((await this.cipherService.get(cipherId)) != null) { - await this.editCipherId(cipherId); + if (await this.cipherService.get(cipherId)) { + if (params.action === "view") { + await this.viewCipherById(cipherId); + } else { + await this.editCipherId(cipherId); + } } else { this.toastService.showToast({ variant: "error", @@ -626,7 +638,7 @@ export class VaultComponent implements OnInit, OnDestroy { !(await this.passwordRepromptService.showPasswordPrompt()) ) { // didn't pass password prompt, so don't open add / edit modal - this.go({ cipherId: null, itemId: null }); + this.go({ cipherId: null, itemId: null, action: null }); return; } @@ -653,12 +665,64 @@ export class VaultComponent implements OnInit, OnDestroy { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises modal.onClosedPromise().then(() => { - this.go({ cipherId: null, itemId: null }); + this.go({ cipherId: null, itemId: null, action: null }); }); return childComponent; } + /** + * Takes a CipherView and opens a dialog where it can be viewed (wraps viewCipherById). + * @param cipher - CipherView + * @returns Promise + */ + viewCipher(cipher: CipherView) { + return this.viewCipherById(cipher.id); + } + + /** + * Takes a cipher id and opens a dialog where it can be viewed. + * @param id - string + * @returns Promise + */ + async viewCipherById(id: string) { + const cipher = await this.cipherService.get(id); + // If cipher exists (cipher is null when new) and MP reprompt + // is on for this cipher, then show password reprompt. + if ( + cipher && + cipher.reprompt !== 0 && + !(await this.passwordRepromptService.showPasswordPrompt()) + ) { + // Didn't pass password prompt, so don't open add / edit modal. + this.go({ cipherId: null, itemId: null }); + return; + } + + // Decrypt the cipher. + const cipherView = await cipher.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(cipher), + ); + + // Open the dialog. + const dialogRef = openViewCipherDialog(this.dialogService, { + data: { cipher: cipherView }, + }); + + // Wait for the dialog to close. + const result: ViewCipherDialogCloseResult = await lastValueFrom(dialogRef.closed); + + // If the dialog was closed by deleting the cipher, refresh the vault. + if (result.action === ViewCipherDialogResult.deleted) { + this.refresh(); + } + + // If the dialog was closed by any other action (close button, escape key, etc), navigate back to the vault. + if (!result.action) { + this.go({ cipherId: null, itemId: null, action: null }); + } + } + async addCollection() { const dialog = openCollectionDialog(this.dialogService, { data: { diff --git a/apps/web/src/app/vault/individual-vault/vault.module.ts b/apps/web/src/app/vault/individual-vault/vault.module.ts index a6cb41bacb8..712b86a9803 100644 --- a/apps/web/src/app/vault/individual-vault/vault.module.ts +++ b/apps/web/src/app/vault/individual-vault/vault.module.ts @@ -10,6 +10,7 @@ import { OrganizationBadgeModule } from "./organization-badge/organization-badge import { PipesModule } from "./pipes/pipes.module"; import { VaultRoutingModule } from "./vault-routing.module"; import { VaultComponent } from "./vault.component"; +import { ViewComponent } from "./view.component"; @NgModule({ imports: [ @@ -23,6 +24,7 @@ import { VaultComponent } from "./vault.component"; BulkDialogsModule, CollectionDialogModule, VaultComponent, + ViewComponent, ], }) export class VaultModule {} diff --git a/apps/web/src/app/vault/individual-vault/view.component.html b/apps/web/src/app/vault/individual-vault/view.component.html new file mode 100644 index 00000000000..a70f1be49d7 --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/view.component.html @@ -0,0 +1,25 @@ + + + {{ cipherTypeString }} + + + + + + +
+ +
+
+
diff --git a/apps/web/src/app/vault/individual-vault/view.component.spec.ts b/apps/web/src/app/vault/individual-vault/view.component.spec.ts new file mode 100644 index 00000000000..fec97e202ef --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/view.component.spec.ts @@ -0,0 +1,117 @@ +import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; +import { mock } from "jest-mock-extended"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { DialogService, ToastService } from "@bitwarden/components"; + +import { ViewComponent, ViewCipherDialogParams, ViewCipherDialogResult } from "./view.component"; + +describe("ViewComponent", () => { + let component: ViewComponent; + let fixture: ComponentFixture; + let router: Router; + + const mockCipher: CipherView = { + id: "cipher-id", + type: 1, + organizationId: "org-id", + isDeleted: false, + } as CipherView; + + const mockOrganization: Organization = { + id: "org-id", + name: "Test Organization", + } as Organization; + + const mockParams: ViewCipherDialogParams = { + cipher: mockCipher, + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ViewComponent], + providers: [ + { provide: DIALOG_DATA, useValue: mockParams }, + { provide: DialogRef, useValue: mock() }, + { provide: I18nService, useValue: { t: jest.fn().mockReturnValue("login") } }, + { provide: DialogService, useValue: mock() }, + { provide: CipherService, useValue: mock() }, + { provide: ToastService, useValue: mock() }, + { provide: MessagingService, useValue: mock() }, + { provide: LogService, useValue: mock() }, + { + provide: OrganizationService, + useValue: { get: jest.fn().mockResolvedValue(mockOrganization) }, + }, + { provide: Router, useValue: mock() }, + { provide: CollectionService, useValue: mock() }, + { provide: FolderService, useValue: mock() }, + { provide: CryptoService, useValue: mock() }, + { + provide: BillingAccountProfileStateService, + useValue: mock(), + }, + { provide: ConfigService, useValue: mock() }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ViewComponent); + component = fixture.componentInstance; + router = TestBed.inject(Router); + component.params = mockParams; + component.cipher = mockCipher; + }); + + describe("ngOnInit", () => { + it("initializes the component with cipher and organization", async () => { + await component.ngOnInit(); + + expect(component.cipher).toEqual(mockCipher); + expect(component.organization).toEqual(mockOrganization); + }); + }); + + describe("edit", () => { + it("navigates to the edit route and closes the dialog with the proper arguments", async () => { + jest.spyOn(router, "navigate").mockResolvedValue(true); + const dialogRefCloseSpy = jest.spyOn(component["dialogRef"], "close"); + + await component.edit(); + + expect(router.navigate).toHaveBeenCalledWith([], { + queryParams: { + itemId: mockCipher.id, + action: "edit", + organizationId: mockCipher.organizationId, + }, + }); + expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: ViewCipherDialogResult.edited }); + }); + }); + + describe("delete", () => { + it("calls the delete method on delete and closes the dialog with the proper arguments", async () => { + const deleteSpy = jest.spyOn(component, "delete"); + const dialogRefCloseSpy = jest.spyOn(component["dialogRef"], "close"); + jest.spyOn(component["dialogService"], "openSimpleDialog").mockResolvedValue(true); + + await component.delete(); + + expect(deleteSpy).toHaveBeenCalled(); + expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: ViewCipherDialogResult.deleted }); + }); + }); +}); diff --git a/apps/web/src/app/vault/individual-vault/view.component.ts b/apps/web/src/app/vault/individual-vault/view.component.ts new file mode 100644 index 00000000000..2a3865cd1d0 --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/view.component.ts @@ -0,0 +1,193 @@ +import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component, Inject, OnInit, EventEmitter, OnDestroy } from "@angular/core"; +import { Router } from "@angular/router"; +import { Subject } from "rxjs"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + AsyncActionsModule, + DialogModule, + DialogService, + ToastService, +} from "@bitwarden/components"; + +import { CipherViewComponent } from "../../../../../../libs/vault/src/cipher-view/cipher-view.component"; +import { SharedModule } from "../../shared/shared.module"; + +export interface ViewCipherDialogParams { + cipher: CipherView; +} + +export enum ViewCipherDialogResult { + edited = "edited", + deleted = "deleted", +} + +export interface ViewCipherDialogCloseResult { + action: ViewCipherDialogResult; +} + +/** + * Component for viewing a cipher, presented in a dialog. + */ +@Component({ + selector: "app-vault-view", + templateUrl: "view.component.html", + standalone: true, + imports: [CipherViewComponent, CommonModule, AsyncActionsModule, DialogModule, SharedModule], +}) +export class ViewComponent implements OnInit, OnDestroy { + cipher: CipherView; + onDeletedCipher = new EventEmitter(); + cipherTypeString: string; + cipherEditUrl: string; + organization: Organization; + restrictProviderAccess = false; + + protected destroy$ = new Subject(); + + constructor( + @Inject(DIALOG_DATA) public params: ViewCipherDialogParams, + private dialogRef: DialogRef, + private i18nService: I18nService, + private dialogService: DialogService, + private messagingService: MessagingService, + private logService: LogService, + private cipherService: CipherService, + private toastService: ToastService, + private organizationService: OrganizationService, + private router: Router, + private configService: ConfigService, + ) {} + + /** + * Lifecycle hook for component initialization. + */ + async ngOnInit() { + this.cipher = this.params.cipher; + this.cipherTypeString = this.getCipherViewTypeString(); + if (this.cipher.organizationId) { + this.organization = await this.organizationService.get(this.cipher.organizationId); + } + this.restrictProviderAccess = await this.configService.getFeatureFlag( + FeatureFlag.RestrictProviderAccess, + ); + } + + /** + * Lifecycle hook for component destruction. + */ + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** + * Method to handle cipher deletion. Called when a user clicks the delete button. + */ + delete = async () => { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "deleteItem" }, + content: { + key: this.cipher.isDeleted ? "permanentlyDeleteItemConfirmation" : "deleteItemConfirmation", + }, + type: "warning", + }); + + if (!confirmed) { + return; + } + + try { + await this.deleteCipher(); + this.toastService.showToast({ + variant: "success", + title: this.i18nService.t("success"), + message: this.i18nService.t( + this.cipher.isDeleted ? "permanentlyDeletedItem" : "deletedItem", + ), + }); + this.onDeletedCipher.emit(this.cipher); + this.messagingService.send( + this.cipher.isDeleted ? "permanentlyDeletedCipher" : "deletedCipher", + ); + } catch (e) { + this.logService.error(e); + } + + this.dialogRef.close({ action: ViewCipherDialogResult.deleted }); + }; + + /** + * Helper method to delete cipher. + */ + protected async deleteCipher(): Promise { + const asAdmin = this.organization?.canEditAllCiphers(this.restrictProviderAccess); + if (this.cipher.isDeleted) { + await this.cipherService.deleteWithServer(this.cipher.id, asAdmin); + } else { + await this.cipherService.softDeleteWithServer(this.cipher.id, asAdmin); + } + } + + /** + * Method to handle cipher editing. Called when a user clicks the edit button. + */ + async edit(): Promise { + this.dialogRef.close({ action: ViewCipherDialogResult.edited }); + await this.router.navigate([], { + queryParams: { + itemId: this.cipher.id, + action: "edit", + organizationId: this.cipher.organizationId, + }, + }); + } + + /** + * Method to get cipher view type string, used for the dialog title. + * E.g. "View login" or "View note". + * @returns The localized string for the cipher type + */ + getCipherViewTypeString(): string { + if (!this.cipher) { + return null; + } + + switch (this.cipher.type) { + case CipherType.Login: + return this.i18nService.t("viewItemType", this.i18nService.t("typeLogin").toLowerCase()); + case CipherType.SecureNote: + return this.i18nService.t("viewItemType", this.i18nService.t("note").toLowerCase()); + case CipherType.Card: + return this.i18nService.t("viewItemType", this.i18nService.t("typeCard").toLowerCase()); + case CipherType.Identity: + return this.i18nService.t("viewItemType", this.i18nService.t("typeIdentity").toLowerCase()); + default: + return null; + } + } +} + +/** + * Strongly typed helper to open a cipher view dialog + * @param dialogService Instance of the dialog service that will be used to open the dialog + * @param config Configuration for the dialog + * @returns A reference to the opened dialog + */ +export function openViewCipherDialog( + dialogService: DialogService, + config: DialogConfig, +): DialogRef { + return dialogService.open(ViewComponent, config); +} diff --git a/apps/web/src/app/vault/org-vault/vault.component.ts b/apps/web/src/app/vault/org-vault/vault.component.ts index a2aca71cde4..1e38cd152e9 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.ts +++ b/apps/web/src/app/vault/org-vault/vault.component.ts @@ -89,6 +89,12 @@ import { RoutedVaultFilterModel, Unassigned, } from "../individual-vault/vault-filter/shared/models/routed-vault-filter.model"; +import { + openViewCipherDialog, + ViewCipherDialogCloseResult, + ViewCipherDialogResult, + ViewComponent, +} from "../individual-vault/view.component"; import { VaultHeaderComponent } from "../org-vault/vault-header/vault-header.component"; import { getNestedCollectionTree } from "../utils/collection-utils"; @@ -121,6 +127,7 @@ enum AddAccessStatusType { VaultItemsModule, SharedModule, NoItemsModule, + ViewComponent, ], providers: [RoutedVaultFilterService, RoutedVaultFilterBridgeService], }) @@ -517,7 +524,11 @@ export class VaultComponent implements OnInit, OnDestroy { (await firstValueFrom(allCipherMap$))[cipherId] != undefined; if (canEditCipher) { - await this.editCipherId(cipherId); + if (qParams.action === "view") { + await this.viewCipherById(cipherId); + } else { + await this.editCipherId(cipherId); + } } else { this.toastService.showToast({ variant: "error", @@ -848,12 +859,64 @@ export class VaultComponent implements OnInit, OnDestroy { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises modal.onClosedPromise().then(() => { - this.go({ cipherId: null, itemId: null }); + this.go({ cipherId: null, itemId: null, action: null }); }); return childComponent; } + /** + * Takes a CipherView and opens a dialog where it can be viewed (wraps viewCipherById). + * @param cipher - CipherView + * @returns Promise + */ + viewCipher(cipher: CipherView) { + return this.viewCipherById(cipher.id); + } + + /** + * Takes a cipher id and opens a dialog where it can be viewed. + * @param id - string + * @returns Promise + */ + async viewCipherById(id: string) { + const cipher = await this.cipherService.get(id); + // if cipher exists (cipher is null when new) and MP reprompt + // is on for this cipher, then show password reprompt. + if ( + cipher && + cipher.reprompt !== 0 && + !(await this.passwordRepromptService.showPasswordPrompt()) + ) { + // didn't pass password prompt, so don't open add / edit modal. + this.go({ cipherId: null, itemId: null }); + return; + } + + // Decrypt the cipher. + const cipherView = await cipher.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(cipher), + ); + + // Open the dialog. + const dialogRef = openViewCipherDialog(this.dialogService, { + data: { cipher: cipherView }, + }); + + // Wait for the dialog to close. + const result: ViewCipherDialogCloseResult = await lastValueFrom(dialogRef.closed); + + // If the dialog was closed by deleting the cipher, refresh the vault. + if (result.action === ViewCipherDialogResult.deleted) { + this.refresh(); + } + + // If the dialog was closed by any other action (close button, escape key, etc), navigate back to the vault. + if (!result.action) { + this.go({ cipherId: null, itemId: null, action: null }); + } + } + async cloneCipher(cipher: CipherView) { if (cipher.login?.hasFido2Credentials) { const confirmed = await this.dialogService.openSimpleDialog({ diff --git a/apps/web/src/app/vault/org-vault/vault.module.ts b/apps/web/src/app/vault/org-vault/vault.module.ts index 7a874ad612c..db8d2256f52 100644 --- a/apps/web/src/app/vault/org-vault/vault.module.ts +++ b/apps/web/src/app/vault/org-vault/vault.module.ts @@ -4,6 +4,7 @@ import { LooseComponentsModule } from "../../shared/loose-components.module"; import { SharedModule } from "../../shared/shared.module"; import { OrganizationBadgeModule } from "../../vault/individual-vault/organization-badge/organization-badge.module"; import { CollectionDialogModule } from "../components/collection-dialog"; +import { ViewComponent } from "../individual-vault/view.component"; import { CollectionBadgeModule } from "./collection-badge/collection-badge.module"; import { GroupBadgeModule } from "./group-badge/group-badge.module"; @@ -20,6 +21,7 @@ import { VaultComponent } from "./vault.component"; OrganizationBadgeModule, CollectionDialogModule, VaultComponent, + ViewComponent, ], }) export class VaultModule {} diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 41af62c55c5..d9a5fc444d3 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -475,6 +475,15 @@ "viewItem": { "message": "View item" }, + "viewItemType": { + "message": "View $ITEMTYPE$", + "placeholders": { + "itemtype": { + "content": "$1", + "example": "login" + } + } + }, "new": { "message": "New", "description": "for adding new items"